diff --git a/.editorconfig b/.editorconfig index 2a5ddba..a7c44dd 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,6 +1,3 @@ -; This file is for unifying the coding style for different editors and IDEs. -; More information at https://editorconfig.org - root = true [*] @@ -14,5 +11,5 @@ trim_trailing_whitespace = true [*.md] trim_trailing_whitespace = false -[*.yml] +[*.{yml,yaml}] indent_size = 2 diff --git a/.gitattributes b/.gitattributes index 7d1de53..aa8ebc7 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,12 +2,14 @@ # https://www.kernel.org/pub/software/scm/git/docs/gitattributes.html # Ignore all test and documentation with "export-ignore". -/.gitattributes export-ignore -/.gitignore export-ignore -/.travis.yml export-ignore -/phpunit.xml.dist export-ignore -/tests export-ignore -/.editorconfig export-ignore -/.php.cs export-ignore -/.github export-ignore - +/.github export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/phpunit.xml.dist export-ignore +/psalm.xml.dist export-ignore +/tests export-ignore +/.editorconfig export-ignore +/.php-cs-fixer.dist.php export-ignore +/art export-ignore +/docs export-ignore +/UPGRADING.md export-ignore diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index fe5143b..5ccc87c 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,2 +1 @@ github: spatie -custom: https://spatie.be/open-source/support-us diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 0000000..a9933e1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,58 @@ +name: Bug Report +description: Report an Issue or Bug with the Package +title: "[Bug]: " +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + We're sorry to hear you have a problem. Can you help us solve it by providing the following details. + - type: textarea + id: what-happened + attributes: + label: What happened? + description: What did you expect to happen? + placeholder: I cannot currently do X thing because when I do, it breaks X thing. + validations: + required: true + - type: textarea + id: how-to-reproduce + attributes: + label: How to reproduce the bug + description: How did this occur, please add any config values used and provide a set of reliable steps if possible. + placeholder: When I do X I see Y. + validations: + required: true + - type: input + id: package-version + attributes: + label: Package Version + description: What version of our Package are you running? Please be as specific as possible + placeholder: 2.0.0 + validations: + required: true + - type: input + id: php-version + attributes: + label: PHP Version + description: What version of PHP are you running? Please be as specific as possible + placeholder: 8.2.0 + validations: + required: true + - type: dropdown + id: operating-systems + attributes: + label: Which operating systems does with happen with? + description: You may select more than one. + multiple: true + options: + - macOS + - Windows + - Linux + - type: textarea + id: notes + attributes: + label: Notes + description: Use this field to provide any other notes that you feel might be relevant to the issue. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..461d155 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,11 @@ +blank_issues_enabled: false +contact_links: + - name: Ask a question + url: https://github.com/spatie/typescript-transformer/discussions/new?category=q-a + about: Ask the community for help + - name: Request a feature + url: https://github.com/spatie/typescript-transformer/discussions/new?category=ideas + about: Share ideas for new features + - name: Report a security issue + url: https://github.com/spatie/typescript-transformer/security/policy + about: Learn how to notify us for sensitive bugs diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..30c8a49 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +# Please see the documentation for all configuration options: +# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + labels: + - "dependencies" \ No newline at end of file diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml new file mode 100644 index 0000000..4af8e6b --- /dev/null +++ b/.github/workflows/dependabot-auto-merge.yml @@ -0,0 +1,32 @@ +name: dependabot-auto-merge +on: pull_request_target + +permissions: + pull-requests: write + contents: write + +jobs: + dependabot: + runs-on: ubuntu-latest + if: ${{ github.actor == 'dependabot[bot]' }} + steps: + + - name: Dependabot metadata + id: metadata + uses: dependabot/fetch-metadata@v1.5.1 + with: + github-token: "${{ secrets.GITHUB_TOKEN }}" + + - name: Auto-merge Dependabot PRs for semver-minor updates + if: ${{steps.metadata.outputs.update-type == 'version-update:semver-minor'}} + run: gh pr merge --auto --merge "$PR_URL" + env: + PR_URL: ${{github.event.pull_request.html_url}} + GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} + + - name: Auto-merge Dependabot PRs for semver-patch updates + if: ${{steps.metadata.outputs.update-type == 'version-update:semver-patch'}} + run: gh pr merge --auto --merge "$PR_URL" + env: + PR_URL: ${{github.event.pull_request.html_url}} + GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} diff --git a/.github/workflows/php-cs-fixer.yml b/.github/workflows/fix-php-code-style-issues.yml similarity index 69% rename from .github/workflows/php-cs-fixer.yml rename to .github/workflows/fix-php-code-style-issues.yml index f55d1fa..96e9a2d 100644 --- a/.github/workflows/php-cs-fixer.yml +++ b/.github/workflows/fix-php-code-style-issues.yml @@ -8,16 +8,16 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v4 with: ref: ${{ github.head_ref }} - name: Run PHP CS Fixer uses: docker://oskarstark/php-cs-fixer-ga with: - args: --config=.php_cs.dist.php --allow-risky=yes + args: --config=.php-cs-fixer.dist.php --allow-risky=yes - name: Commit changes - uses: stefanzweifel/git-auto-commit-action@v4 + uses: stefanzweifel/git-auto-commit-action@v5 with: commit_message: Fix styling diff --git a/.github/workflows/psalm.yml b/.github/workflows/psalm.yml deleted file mode 100644 index a2c9a01..0000000 --- a/.github/workflows/psalm.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: Psalm - -on: - push: - paths: - - '**.php' - - 'psalm.xml' - -jobs: - psalm: - name: psalm - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: '8.2' - extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick - coverage: none - - - name: Cache composer dependencies - uses: actions/cache@v3 - with: - path: vendor - key: composer-${{ hashFiles('composer.lock') }} - - - name: Run composer install - run: composer install -n --prefer-dist - - - name: Run psalm - run: ./vendor/bin/psalm -c psalm.xml diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index f39087f..25ca553 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -1,4 +1,4 @@ -name: run-tests +name: Tests on: [push, pull_request] @@ -8,31 +8,30 @@ jobs: strategy: fail-fast: true matrix: - os: [ubuntu-latest] - php: [8.0, 8.1, 8.2] - dependency-version: [prefer-lowest, prefer-stable] + os: [ubuntu-latest, windows-latest] + php: [8.2, 8.1] + stability: [prefer-lowest, prefer-stable] - name: P${{ matrix.php }} - ${{ matrix.dependency-version }} - ${{ matrix.os }} + name: P${{ matrix.php }} - ${{ matrix.stability }} - ${{ matrix.os }} steps: - name: Checkout code uses: actions/checkout@v3 - - name: Cache dependencies - uses: actions/cache@v3 - with: - path: ~/.composer/cache/files - key: dependencies-php-${{ matrix.php }}-composer-${{ hashFiles('composer.json') }} - - name: Setup PHP uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} - extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick + extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick, fileinfo coverage: none + - name: Setup problem matchers + run: | + echo "::add-matcher::${{ runner.tool_cache }}/php.json" + echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" + - name: Install dependencies - run: composer update --${{ matrix.dependency-version }} --prefer-dist --no-interaction + run: composer update --${{ matrix.stability }} --prefer-dist --no-interaction - name: Execute tests run: vendor/bin/pest diff --git a/.github/workflows/update-changelog.yml b/.github/workflows/update-changelog.yml index fa56639..8c12ba9 100644 --- a/.github/workflows/update-changelog.yml +++ b/.github/workflows/update-changelog.yml @@ -4,13 +4,16 @@ on: release: types: [released] +permissions: + contents: write + jobs: update: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: ref: main diff --git a/.gitignore b/.gitignore index 192204a..841e6e5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,13 @@ +.idea +.php_cs +.php_cs.cache +.phpunit.result.cache build composer.lock -vendor coverage -.phpunit.result.cache -.idea -.php_cs.cache +docs +phpunit.xml +psalm.xml +vendor .php-cs-fixer.cache + diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..3723623 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +yarn lint-staged diff --git a/.php_cs.dist.php b/.php-cs-fixer.dist.php similarity index 87% rename from .php_cs.dist.php rename to .php-cs-fixer.dist.php index 3de28fd..ea229df 100644 --- a/.php_cs.dist.php +++ b/.php-cs-fixer.dist.php @@ -12,7 +12,7 @@ return (new PhpCsFixer\Config()) ->setRules([ - '@PSR2' => true, + '@PSR12' => true, 'array_syntax' => ['syntax' => 'short'], 'ordered_imports' => ['sort_algorithm' => 'alpha'], 'no_unused_imports' => true, @@ -26,11 +26,6 @@ ], 'phpdoc_single_line_var_spacing' => true, 'phpdoc_var_without_name' => true, - 'class_attributes_separation' => [ - 'elements' => [ - 'method' => 'one', - ], - ], 'method_argument_space' => [ 'on_multiline' => 'ensure_fully_multiline', 'keep_multiple_spaces_after_comma' => true, diff --git a/CHANGELOG.md b/CHANGELOG.md index dd6add0..0faddee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,120 +1,4 @@ # Changelog -All notable changes to `typescript-transformer` will be documented in this file +All notable changes to `typescript-transformer` will be documented in this file. -## 2.2.0 - 2023-06-02 - -- Add support for hidden properties (#54) - -## 2.1.14 - 2023-04-07 - -- add support for record types (#51) - -## 2.1.13 - 2023-02-01 - -- Add EnumCollector (#42) -- Ensure transformed types are unique (#44) - -## 2.1.12 - 2022-11-18 - -- add support for optional attributes (#30) -- refactor tests to Pest (#39) - -## 2.1.11 - 2022-09-28 - -- fix: Support Collection with array-key key type (#38) - -## 2.1.10 - 2022-07-04 - -- Allow non fully qualified names within annotations - -## 2.1.9 - 2022-06-29 - -- allow transformation of interfaces (#32) - -## 2.1.8 - 2022-04-29 - -- add eslint formatter(#28) -- let prettier formatter use `npx` (#29) - -## 2.1.7 - 2022-04-06 - -- Allow whitespace in type definitions (#27 ) - -## 2.1.6 - 2022-01-05 - -- fix the transformation of PHP native enums - -## 2.1.5 - 2021-12-29 - -## What's Changed - -- Make compatible with Symfony 6.0 Process component by @firstred in https://github.com/spatie/typescript-transformer/pull/17 - -## New Contributors - -- @firstred made their first contribution in https://github.com/spatie/typescript-transformer/pull/17 - -**Full Changelog**: https://github.com/spatie/typescript-transformer/compare/2.1.4...2.1.5 - -## 2.1.4 - 2021-12-23 - -- allow interfaces in default type replacements - -## 2.1.3 - 2021-12-16 - -- add support for transforming to native TypeScript enums - -## 2.1.2 - 2021-12-16 - -- fix deprecations - -## 2.1.1 - 2021-12-08 - -- add support for PHP 8.1 (#15) - -## 2.1.0 - 2021-04-08 - -- Remove classtools dependency -- Add support for PHP 8.1 enums (#12) -- Add `declare` keyword by default to generated output (#13) - -## 2.0.3 - 2021-07-09 - -- Fix `ProcessTypes` to work with Collection types - -## 2.0.2 - 2021-06-30 - -- Fix default collector with missing symbols in attributes - -## 2.0.1 - 2021-04-14 - -- Allow spatie/temporary-directory v2 on dev - -## 2.0.0 - 2021-04-08 - -- The package is now PHP 8 only -- Added TypeReflectors to reflect method return types, method parameters & class properties within your transformers -- Added support for attributes -- Added support for manually adding TypeScript to a class or property -- Added formatters like Prettier which can format TypeScript code -- Added support for inlining types directly -- Updated the DtoTransformer to be a lot more flexible for your own projects -- Added support for PHP 8 union types - -## 1.1.2 - 2021-01-07 - -- Add support for `Writers` (#7) - -## 1.1.1 - 2020-11-26 - -- Add PHP8 support - -## 1.1.0 - 2020-11-26 - -- Fix some capitalization in namespace names -- Added `SpatieEnumTransformer` from the `laravel-typescript-transformer` package - -## 1.0.0 - 2020-09-02 - -- initial release diff --git a/LICENSE.md b/LICENSE.md index 59e5ec5..29f5863 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) Spatie bvba +Copyright (c) spatie Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 62619a0..2a497d8 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,15 @@ - -[](https://supportukrainenow.org) - -# Transform PHP types to TypeScript +# This is my package typescript-transformer [![Latest Version on Packagist](https://img.shields.io/packagist/v/spatie/typescript-transformer.svg?style=flat-square)](https://packagist.org/packages/spatie/typescript-transformer) -[![Tests](https://github.com/spatie/typescript-transformer/workflows/run-tests/badge.svg)](https://github.com/spatie/typescript-transformer/actions?query=workflow%3Arun-tests) -[![Styling](https://github.com/spatie/typescript-transformer/workflows/Check%20&%20fix%20styling/badge.svg)](https://github.com/spatie/typescript-transformer/actions?query=workflow%3A%22Check+%26+fix+styling%22) -[![Psalm](https://github.com/spatie/typescript-transformer/workflows/Psalm/badge.svg)](https://github.com/spatie/typescript-transformer/actions?query=workflow%3APsalm) +[![Tests](https://img.shields.io/github/actions/workflow/status/spatie/typescript-transformer/run-tests.yml?branch=main&label=tests&style=flat-square)](https://github.com/spatie/typescript-transformer/actions/workflows/run-tests.yml) [![Total Downloads](https://img.shields.io/packagist/dt/spatie/typescript-transformer.svg?style=flat-square)](https://packagist.org/packages/spatie/typescript-transformer) -This package allows you to convert PHP classes to TypeScript. +This package allows you to convert PHP classes to TypeScript. This class... ```php -/** @typescript */ +#[TypeScript] class User { public int $id; @@ -36,10 +31,10 @@ export type User = { Here's another example. ```php -class Languages extends Enum +enum Languages: string { - const TYPESCRIPT = 'typescript'; - const PHP = 'php'; + case TYPESCRIPT = 'typescript'; + case PHP = 'php'; } ``` @@ -49,27 +44,1237 @@ The `Languages` enum will be converted to: export type Languages = 'typescript' | 'php'; ``` -You can find the full documentation [here](https://docs.spatie.be/typescript-transformer/v2/introduction/). - ## Support us [](https://spatie.be/github-ad-click/typescript-transformer) -We invest a lot of resources into creating [best in class open source packages](https://spatie.be/open-source). You can support us by [buying one of our paid products](https://spatie.be/open-source/support-us). +We invest a lot of resources into creating [best in class open source packages](https://spatie.be/open-source). You can +support us by [buying one of our paid products](https://spatie.be/open-source/support-us). -We highly appreciate you sending us a postcard from your hometown, mentioning which of our package(s) you are using. You'll find our address on [our contact page](https://spatie.be/about-us). We publish all received postcards on [our virtual postcard wall](https://spatie.be/open-source/postcards). +We highly appreciate you sending us a postcard from your hometown, mentioning which of our package(s) you are using. +You'll find our address on [our contact page](https://spatie.be/about-us). We publish all received postcards +on [our virtual postcard wall](https://spatie.be/open-source/postcards). ## Installation -You can install this package via composer: +You can install the package via composer: ```bash composer require spatie/typescript-transformer ``` -## Testing +## Setting up TypeScript transformer + +We first need to initialize typescript-transformer and configure what it exactly should do. If you're using Laravel, +please skip to the next section. + +Since TypeScript transformer is framework-agnostic, we cannot provide you exact steps on how to integrate it into your +application. However, we can provide you with a general idea of how to do it. + +Ideally, TypeScript transformer is a CLI command within your application, that can be quickly called when you need to +generate TypeScript types. + +Within Symphony, for example, you can create a command like this: + +```php +use Spatie\TypeScriptTransformer\TypeScriptTransformer; +use Spatie\TypeScriptTransformer\TypeScriptTransformerConfigFactory; + +class GenerateTypeScriptCommand extends Command +{ + protected static $defaultName = 'typescript:transform'; + + protected function configure() + { + $this->setDescription('Transform TypeScript types'); + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + $config = TypeScriptTransformerConfigFactory::create(); // We'll come back to this in a minute + + TypeScriptTransformer::create($config)->execute(); + } +} +``` + +When you've registered the command, it can be executed as such: + +```bash +php bin/console typescript:transform +``` + +Since we haven't configured TypeScript transformer yet, this command won't do anything. Skip the Laravel section and +continue with the next section to learn how to configure TypeScript transformer. + +### Laravel + +When using Laravel, first install the specific `TypeScriptTransformerServiceProvider`: + +```bash +php artisan typescript:install +``` + +This command will create a `TypeScriptTransformerServiceProvider` in your `app/Providers` directory. Which looks like +this: + +```php +class TypeScriptTransformerServiceProvider extends BaseTypeScriptTransformerServiceProvider +{ + protected function configure(TypeScriptTransformerConfigFactory $config): void + { + $config; // We'll come back to this in a minute + } +} +``` + +And it will also register the service provider in your `bootstrap/providers.php` file (when running Laravel 11 or +above). Or in your `config/app.php` file when running Laravel 10 or below. + +Now you can transform types as such: + +```bash +php artisan typescript:transform +``` + +Since we haven't configured TypeScript transformer yet, this command won't do anything. Let's do that now. + +## Running TypeScript Transformer for the first time + +TypeScript transformer is a highly configurable framework to transform PHP classes and more into TypeScript types, we +provide some highly used functionality out of the box, but you can configure it to your needs. + +We're going to start with transforming basic PHP classes to TypeScript types, this is what the package actually does: + +1. It starts searching for PHP classes within your application +2. It makes a ReflectionClass from each of these found classes +3. These ReflectionClasses are then processed by a list of transformers (they take a ReflectionClass and try to make a + TypeScript type from it) +4. If a ReflectionClass is transformed, it is added to a list to be written to TypeScript otherwise the class is ignored +5. That list is then written to a TypeScript file + +Transformers are the most important part in this whole process, they implement the `Transformer` interface which looks +like this: + +```php +interface Transformer +{ + public function transform(ReflectionClass $reflectionClass, TransformationContext $context): Transformed|Untransformable; +} +``` + +By default, the package comes with a few transformers: + +- `EnumTransformer`: Transforms PHP enums to TypeScript enums +- `ClassTransformer`: Transforms PHP classes with its properties to TypeScript types (abstract, read on for more info) +- `AttributedClassTransformer`: A special version of the `ClassTransformer` that only transforms classes with + the `#[TypeScript]` attribute +- `LaravelClassTransformer`: A special version of the `ClassTransformer` with some goodies for Laravel users + +You're free to mix and match these transformers to your needs, or even create your own transformers. + +Registering can be done as such within your TypeScript CLI command or `TypeScriptTransformerServiceProvider` (if you're +using Laravel): + +```php +$config->transformer(AttributedClassTransformer::class); +``` + +Since transformers are just PHP classes, you can also pass them arguments when initializing them: + +```php +$config->transformer(new EnumTransformer(useNativeEnums: true)); // transformers enums as TypeScript native enums and not as a union of strings +``` + +Quick note: transformers are executed in the order they are registered in the configuration, when a transformer cannot +transform a class, the next transformer is executed. + +Transformers work on PHP classes, we need to tell TypeScript transformer where to look for these classes. This can be +done by adding a directory to the configuration: + +```php +$config->watchDirectories(app_path()); +``` + +We're almost done! The last thing we need to do is tell TypeScript transformer how to write types, this can be done by +using the `NamespaceWriter` which writes all types to a single TypeScript file with namespaces: + +```ts +declare namespace App.Data { + export type PostData = { + title: string; + slug: string; + type: App.Enums.PostType; + tags: Array; + publish_date: string | null; + published: boolean; + }; +} +declare namespace App.Enums { + export type PostType = 'news' | 'blog'; +} +``` + +You can configure this writer and where it should put the file as such: + +```php +$config->writeTypes(new NamespaceWriter(resource_path('types/generated.d.ts'))); +``` + +If you want a file per namespace, then you can use the `ModuleWriter`, it will write a structure like this: + +```ts +// app/data/index.d.ts +export type PostData = { + title: string; + slug: string; + type: App.Enums.PostType; + tags: Array; + publish_date: string | null; + published: boolean; +}; + +// app/enums/index.d.ts +export type PostType = 'news' | 'blog'; +``` + +You can configure it like this: + +```php +$config->writeTypes(new ModuleWriter(resource_path('types'))); +``` + +That's it! You're now ready to transform your PHP classes to TypeScript types. If you've configured +the `EnumTransformer` then, every enum should be transformed to TypeScript. When using the `AttributedClassTransformer`, +be sure to add the `#[TypeScript]` attribute to classes you want transformed. + +### Special attributes + +Classes can have attributes that change the way they are transformed, let's go through them. + +Using the `#[TypeScript]` attribute is not only a way to tell typescript-transformer to transform a class, but it can +also be used to change the name of the transformed class: + +```php +#[TypeScript(name: 'UserWithoutEmail')] +class User +{ + public int $id; + public string $name; +} +``` + +This will transform the `User` class to `UserWithoutEmail` in TypeScript. + +```ts +export type UserWithoutEmail = { + id: number; + name: string; +} +``` + +Each type will be located somewhere either being a file when using the `ModuleWriter` or in a single file when using +the `NamespaceWriter`. The location of the type can be changed by using the `#[TypeScript]` attribute: + +```php +#[TypeScript(location: ['Data', 'Users'])] +class User +{ + public int $id; + public string $name; +} +``` + +This will transform as such: + +```ts +declare namespace Data.Users { + export type User = { + id: number; + name: string; + }; +} +``` + +It is possible to completely remove a class from the TypeScript output by using the `#[Hidden]` attribute: + +```php +#[Hidden] +enum Members: string +{ + case John = 'john'; + case Paul = 'paul'; + case George = 'george'; + case Ringo = 'ringo'; +} +``` + +This is particularly useful when using the `EnumTransformer` and you want to hide certain enums from the TypeScript. + +## Making sure PHP classes are typed + +The first run of TypeScript transformer might not have the desired result, a lot of property types could be `undefined` +because TypeScript transformer doesn't know what type these properties are, let's fix that! + +Typescript transformer will automatically transform basic PHP types as such: + +```php +class Types +{ + public string $property; // string + public int $property; // number + public float $property; // number + public bool $property; // boolean + public mixed $property; // any + public object $property; // object +} +``` + +When a type is nullable, TypeScript transformer will transform it as such: + +```php +class Types +{ + public ?string $property; // string | null +} +``` + +Unions and intersections are also supported: + +```php +class Types +{ + public string | int $property; // string | number + public string & int $property; // string & number +} +``` + +Arrays in PHP can be transformed to two types in TypeScript, if no types are annotated, an array will become an `Array`. +When an array is typed with integer keys it will still be an Array. An array typed with string keys will become +a `Record`: + +```php +class Types +{ + public array $property; // Array + + /** @var bool[] */ + public array $property; // Array + + /** @var array */ + public array $property; // Array + + /** @var array */ + public array $property; // Record +} +``` + +As you an see, when an array value is typed correctly, it will also be typed correctly in TypeScript. + +It is also possible to use non-typical array key types, like an enum: + +```php +class Types +{ + /** @var array */ + public array $property; // Record<'news'|'blog', string> +} +``` + +There are multiple locations where you can add property annotations: + +```php +/** +* @property string[] $propertyA + */ +class Types +{ + public array $propertyA; + + /** @var string[] */ + public array $propertyB; + + /** + * @param string[] $propertyC + */ + public function __construct( + public array $propertyC + ) { + + } +} +``` + +Typing objects works like magic: + +```php +class Types +{ + // App.Enums.PostType (when using the NamespaceWriter) + // Import { PostType } from '../enums' + PostType (when using the ModuleWriter) + public PostType $property; +} +``` + +If an typed object is not transformed and thus we don't know how it will look like in TypeScript, it will be replaced +by `unknown`. It is possible to replace these unknown types with a TypeScript type, without transforming them, keep +reading to learn how to do that. + +You can also type generic properties: + +```php +class Types +{ + /** @var Collection */ + public Collection $property; // Illuminate.Support.Collection +} +``` + +Properties can be made optional in TypeScript by adding the `#[Optional]` attribute: + +```php +class Types +{ + #[Optional] + public string $property; +} +``` + +Transforming this class will result in the following object: + +```ts +export type Types = { + property?: string; +} +``` + +It is possible to hide properties from the TypeScript object by adding the `#[Hidden]` attribute: + +```php +class Types +{ + #[Hidden] + public string $property; +} +``` + +When you want to replace a property type with a literal TypeScript type, you can use the `#[LiteralTypeScriptType]` +attribute: + +```php +class Types +{ + #[LiteralTypeScriptType('Record, string>')] + public array $property; +} +``` + +You can also create a TypeScript object from literal types: + +```php +class Types +{ + #[LiteralTypeScriptType([ + 'age' => 'number', + 'name' => 'string', + ])] + public array $property; +} +``` + +This will result in the following TypeScript object: + +```ts +export type Types = { + property: { + age: number; + name: string; + }; +} +``` + +It is also possible to type properties using php types within an attribute using the `#[TypeScriptType]` attribute: + +```php +class Types +{ + #[TypeScriptType('string')] + public $property; +} +``` + +Also, this attribute can be used to type an object, but this time the types can be PHP types: + +```php +class Types +{ + #[TypeScriptType([ + 'age' => 'int', + 'name' => 'string', + ])] + public $property; +} +``` + +## Replacing common types + +Some PHP classes should be transformed into a TypeScript object, an example of this is the `DateTime` class. When you +send such an object to the front it will be represented by a string rather than an object. TypeScript transformer allows +you to replace these kinds types with an appropriate TypeScript type. + +Replacing types can be done in the config: + +```php +$config->replaceType(DateTime::class, 'string'); +``` + +Now all `DateTime` objects will be transformed to a string in TypeScript. This also includes inherited classes +like `Carbon`, those will also be transformed to a string. + +When using an interface like `DateTimeInterface` you can also replace it with a TypeScript type: + +```php +$config->replaceType(DateTimeInterface::class, 'string'); +``` + +All classes that implement `DateTimeInterface` will be transformed to a string in TypeScript. + +### Replacements + +As we've seen before it is possible to replace types by writing them out like you would do in an annotation, this allows +you to build complex types, for example: + +```php +$config->replaceType(DateTimeInterface::class, 'array{day: int, month: int, year: int}'); +``` + +From now on, all `DateTimeInterface` objects will be replaced by the following TypeScript object: + +```ts +{ + day: number; + month: number; + year: number; +} +``` -``` bash +It is also possible to define a replacement as an internal TypeScript node(more on that later): + +```php +$config->replaceType(DateTimeInterface::class, new TypeScriptString()); +``` + +Or use a closure to define the replacement: + +```php +use Spatie\TypeScriptTransformer\TypeScriptNodes\TypeReference; + +$config->replaceType(DateTimeInterface::class, function (TypeReference $reference) { + return new TypeScriptString(); +}); +``` + +## TypeScript nodes + +Internally the package uses TypeScript nodes to represent TypeScript types, these nodes can be used to build complex +types and it is possible to create your own nodes. + +For example, a TypeScript alias is representing a User object looks like this: + +```php +use Spatie\TypeScriptTransformer\TypeScriptNodes; + +new TypeScriptAlias( + new TypeScriptIdentifier('User'), + new TypeScriptObject([ + new TypeScriptProperty('id', new TypeScriptNumber()), + new TypeScriptProperty('name', new TypeScriptString()), + new TypeScriptProperty('address', new TypeScriptUnion([ + new TypeScriptString(), + new TypeScriptNull(), + ])), + ]), +); +``` + +Transforming this alias to TypeScript will result in the following type: + +```ts +type User = { + id: number; + name: string; + address: string | null; +} +``` + +There are a lot of TypeScript nodes available, you can find them in the `Spatie\TypeScriptTransformer\TypeScript` +namespace. In the advanced section we'll take a look at how to build your own TypeScript nodes. + +## Creating a transformer + +Transformers are the most important part of TypeScript transformer, they take a PHP class and try to transform it to a +TypeScript type. A transformer implements the `Transformer` interface: + +```php +interface Transformer +{ + public function transform(ReflectionClass $reflectionClass, TransformationContext $context): Transformed|Untransformable; +} +``` + +The `TransformationContext` contains all the information you need to transform a class: + +```php +class TransformationContext +{ + public function __construct( + // The name for the class that is being transformed, can be user defined + public string $name, + // The segments of the namespace where the class is located + public array $nameSpaceSegments, + ) { + } +} +``` + +Within the method a `Transformed` data object should be created and returned which looks like this: + +```php +use Spatie\TypeScriptTransformer\References\ClassStringReference; + +new Transformed( + // The TypeScript node representing the transformed class + typeScriptNode: $typeScriptNode, + // A unique name for the transformed class + reference: new ClassStringReference($reflectionClass->getName()), + // A location where the class should be written to + // By default, this is the namespace of the class and the $nameSpaceSegments from the TransformationContext can be used + location: $context->nameSpaceSegments, + // Whether the type should be exported in TypeScript + export: true, +); +``` + +If a class cannot be transformed, the `Untransformable` object should be returned: + +```php +use Spatie\TypeScriptTransformer\Untransformable; + +Untransformable::create(); +``` + +When a class cannot be transformed, the next transformer in the list will be executed. + +### Extending the class Transformer + +Most of the time, transforming a class comes down to taking all the properties and transforming them to a TypeScript +object with properties, the package provides an easy-to-extend class for this called `ClassTransformer`. + +You can create your own by extending the `ClassTransformer` and implementing the `shouldTransform` method: + +```php +use Spatie\TypeScriptTransformer\Transformers\ClassTransformer; + +class MyTransformer extends ClassTransformer +{ + protected function shouldTransform(ReflectionClass $reflection): bool + { + return $reflection->implementsInterface(\Spatie\LaravelData\Data::class); + } +} +``` + +In the case above, the transformer will only run when transforming classes which are data objects from +the [laravel-data](https://github.com/spatie/laravel-data) package. We encourage you to overwrite certain methods so +that the transformer fits your needs. + +#### Choosing properties to transform + +By default, all public non-static properties of a class are transformed, but you can overwrite the `properties` method +to change this: + +```php +protected function getProperties(ReflectionClass $reflection): array +{ + return $reflection->getProperties(ReflectionProperty::IS_PUBLIC|ReflectionProperty::IS_PROTECTED); +} +``` + +#### Optional properties + +It is possible to make a property optional in TypeScript by overwriting the `isPropertyReadonly` method: + +```php +protected function isPropertyOptional( + ReflectionProperty $reflectionProperty, + ReflectionClass $reflectionClass, + TypeScriptNode $type, + TransformationContext $context, +): bool { + return str_starts_with($reflectionProperty->getName(), '_'); +} +``` + +By default, we check whether a property has an `#[Optional]` attribute. + +#### Readonly properties + +You can make a property readonly by overwriting the `isPropertyReadonly` method: + +```php +protected function isPropertyReadonly( + ReflectionProperty $reflectionProperty, + ReflectionClass $reflectionClass, + TypeScriptNode $type, +): bool { + return str_ends_with($reflectionProperty->getName(), 'Read'); +} +``` + +By default, we check whether a property was made readonly in PHP. + +#### Hiding properties + +It is possible to completely hide a property from the TypeScript object by overwriting the `isPropertyHidden` method: + +```php +protected function isPropertyHidden( + ReflectionProperty $reflectionProperty, + ReflectionClass $reflectionClass, + TypeScriptProperty $property, +): bool { + return count($reflectionProperty->getAttributes(Hidden::class)) > 0; +} +``` + +By default, we check whether a property has an `#[Hidden]` attribute. + +#### Class property processors + +Sometimes a more fine-grained control is needed over how a property is transformed, this is where class property +processors come to play. They allow you to update the TypeScript Node of the property, you can create them by +implementing the `ClassPropertyProcessor` interface: + +```php +use Spatie\TypeScriptTransformer\Transformers\ClassPropertyProcessors\ClassPropertyProcessor; + +class RemoveNullProcessor implements ClassPropertyProcessor +{ + public function execute( + ReflectionProperty $reflection, + ?TypeNode $annotation, + TypeScriptProperty $property + ): ?TypeScriptProperty { + if ($property->type instanceof TypeScriptUnion) { + $property->type = new TypeScriptUnion( + array_values(array_filter($property->type->types, fn (TypeScriptNode $type) => !$type instanceof TypeScriptNull)) + ); + } + + return $property; + } +} +``` + +You can add these processors to the transformer by overwriting the `classPropertyProcessors` method: + +```php +protected function classPropertyProcessors(): array +{ + return [ + new RemoveNullProcessor(), + ]; +} +``` + +A class property processor can also be used to remove properties from the TypeScript object: + +```php +class RemoveAllStrings implements ClassPropertyProcessor +{ + public function execute( + ReflectionProperty $reflection, + ?TypeNode $annotation, + TypeScriptProperty $property + ): ?TypeScriptProperty { + if ($property->type instanceof TypeScriptString) { + return null; + } + + return $property; + } +} +``` + +## Creating a TypesProvider + +Until now we've only taken a look at transforming PHP classes to TypeScript, but what if you want to transform something +else? This is where the `TypesProvider` comes in, it is a class that provides TypeScript types. The transformers +we've seen before are actually bundled in a default `TypesProvider` provided by the package. + +A `TypesProvider` implements the `TypeProvider` interface: + +```php +namespace Spatie\TypeScriptTransformer\TypeProviders; + +use Spatie\TypeScriptTransformer\Collections\TransformedCollection;use Spatie\TypeScriptTransformer\TypeScriptTransformerConfig; + +interface TypesProvider +{ + public function provide( + TypeScriptTransformerConfig $config, + TransformedCollection $types + ): void; +} +``` + +The `provide` method is called when the TypeScript transformer is executed, it should add `Transformed` objects to the +collection provided. We could for example add a generic type which transforms Laravel collections: + +```php +class AddLaravelCollectionProvider implements TypesProvider +{ + public function provide( + TypeScriptTransformerConfig $config, + TransformedCollection $types + ): void { + $types->add(new Transformed( + typeScriptNode: new TypeScriptAlias( + new TypeScriptGeneric( + new TypeScriptIdentifier('Collection'), + [new TypeScriptIdentifier('T')], + ), + new TypeScriptGeneric( + new TypeScriptIdentifier('Array'), + [new TypeScriptIdentifier('T')], + ), + ), + reference: new ClassStringReference(Collection::class), + location: ['Illuminate', 'Support'] + )); + } +} +``` + +When we register the provider as such in the configuration: + +```php +$config->addProvider(new AddLaravelCollectionProvider()); +``` + +Our transformed TypeScript will have the following type: + +```ts +namespace Illuminate.Support { + export type Collection = Array; +} +``` + +When referencing a Laravel collection in one of our PHP classes like this: + +```php +class Data +{ + /** @var Collection */ + public Collection $collection; +} +``` + +The transformed TypeScript will look like this: + +```ts +export type Data = { + collection: Illuminate.Support.Collection; +} +``` + +## Referencing types + +Types sometimes reference other types like PHP classes referencing other PHP classes. Within the package a concept of +references is used to link these types together. + +When creating `Transformed` objects we've always used the `ClassStringReference` since we were referencing PHP classes, +sometimes you might be transforming something which is not a PHP class for example a list of strings. In this case, you +can use a `CustomReference`: + +```php +use Spatie\TypeScriptTransformer\References\CustomReference; + +new Transformed( + typeScriptNode: new TypeScriptAlias( + new TypeScriptIdentifier('Type'), + new TypeScriptUnion([new TypeScriptLiteral('PHP'), new TypeScriptLiteral('TypeScript')]), + ), + reference: new CustomReference('my_languages_package', 'some_languages'), + location: ['App', 'Languages'], +); +``` + +A custom reference should be unique for each type, that's why it is built up from a group and a name. We advise you when +creating a package (or if you're implementing a feature within your app) to choose a custom group name in order not to +conflict with other packages. + +In the end the transformed TypeScript will look like this: + +```ts +namespace App.Languages { + export type Type = 'PHP' | 'TypeScript'; +} +``` + +It is possible to reference this type in another `Transformed` object: + +```php +new Transformed( + typeScriptNode: new TypeScriptAlias( + new TypeScriptIdentifier('Compiler'), + new TypeScriptObject([ + new TypeScriptProperty('type', new CustomTypeReference('my_languages_package', 'some_languages')), + ]), + ), + reference: new ClassStringReference(Compiler::class), + location: ['App', 'Compilers'], +); +``` + +The transformed TypeScript now will look like this: + +```ts +namespace App.Compilers { + export type Compiler = { + type: App.Languages.Type; + }; +} +``` + +Since we're using the same reference, the package is smart enough to link them together when transforming to TypeScript. + +Off course, you can also reference PHP classes in the same way: + +```php +new Transformed( + typeScriptNode: new TypeScriptAlias( + new TypeScriptIdentifier('Post'), + new TypeScriptObject([ + new TypeScriptProperty('publisher', new TypeScriptReference(new ClassStringReference(User::class))), + ]), + ), + reference: new ClassStringReference(User::class), + location: ['App', 'Models'], +); +``` + +## Formatting TypeScript + +The package tries to format the transformed TypeScript as good as possible, but sometimes this could be far from +perfect. That's why it is possible to automatically format the TypeScript code after transforming. + +By default, the package has support for two formatters: + +- `PrettierFormatter`: Formats the TypeScript code using Prettier +- `EslintFormatter`: Formats the TypeScript code using ESLint + +You can add a formatter to the configuration like this: + +```php +use Spatie\TypeScriptTransformer\Formatters\PrettierFormatter; + +$config->formatter(new PrettierFormatter()); +``` + +It is possible to create your own formatter by implementing the `Formatter` interface: + +```php +interface Formatter +{ + public function format(array $files): void; +} +``` + +The `$files` array contains the TypeScript files that need to be formatted, you can format them in any way you like. + +## Laravel + +### Getting routes as TypeScript + +## Watching changes and live updating TypeScript + +## Advanced concepts + +The package is highly configurable and can be extended in many ways, let's take a look at some advanced concepts. + +### Building your own TypeScript node + +The package comes with a lot of TypeScript nodes, but sometimes it might be necessary to build your own. + +A TypeScript node is a regular PHP class that implements the `TypeScriptNode` interface: + +```php +use Spatie\TypeScriptTransformer\Support\WritingContext; +use Spatie\TypeScriptTransformer\TypeScriptNodes\TypeScriptNamedNode; +use Spatie\TypeScriptTransformer\TypeScriptNodes\TypeScriptNode; + +class PickNode implements TypeScriptNode, TypeScriptNamedNode +{ + public function __construct( + private TypeScriptNode $type, + private array $properties, + ) {} + + public function write(WritingContext $context): string + { + return 'Pick<' . $this->type->write($context) . ', ' . implode(' | ', $this->properties) . '>'; + } + + public function getName(): string + { + return 'Pick'; + } +} +``` + +The write method is responsible for transforming the TypeScript node to a string, the `WritingContext` object is passed +to lower level TypeScript nodes to reference other TypeScript types and can generally be ignored. + +Some TypeScript nodes represent a type with a name like an interface, enum, ... these nodes should implement +the `TypeScriptNamedNode` interface. The `getName` method should return the name of the TypeScript node so that it can +be referenced by other TypeScript nodes. + +When you've got a node which itself contains another TypeScript node that can be a `TypeScriptNamedNode` we recommend +you to implement `TypeScriptForwardingNamedNode`. This interface requires you to implement the `getForwardedNamedNode` +method which should return the TypeScript node that either is another `TypeScriptForwardingNamedNode` +or `TypeScriptNamedNode`. An example of such a node is the `TypeScriptAlias`: + +```php +class TypeScriptAlias implements TypeScriptForwardingNamedNode, TypeScriptNode +{ + public function __construct( + public TypeScriptIdentifier|TypeScriptGeneric $identifier, + public TypeScriptNode $type, + ) { + } + + public function write(WritingContext $context): string + { + return "type {$this->identifier->write($context)} = {$this->type->write($context)};"; + } + + public function getForwardedNamedNode(): TypeScriptNamedNode|TypeScriptForwardingNamedNode + { + return $this->identifier; + } +} +``` + +Lastly, the package also provides some tooling to visit a tree of all TypeScript nodes, when your custom node is +encapsulating other TypeScript nodes you should implement the `TypeScriptVisitableNode` interface which requires you to +implement the visitorProfile method. + +The `visitorProfile` method should return a `VisitorProfile` object which contains information about the properties +of your TypeScript node PHP class that can be visited. We extinguish two types of properties: single nodes properties +containing a single node and iterable properties containing a set of properties. + +The `TypeScriptGeneric` node is an excellent example: + +```php +class TypeScriptGeneric implements TypeScriptForwardingNamedNode, TypeScriptNode, TypeScriptVisitableNode +{ + /** + * @param array $genericTypes + */ + public function __construct( + public TypeScriptIdentifier|TypeReference $type, + public array $genericTypes, + ) { + } + + public function visitorProfile(): VisitorProfile + { + return VisitorProfile::create()->single('type')->iterable('genericTypes'); + } + + // .... +} +``` + +It contains a single node property `type` and an iterable property `genericTypes`. From now on the package will visit +the nodes within these properties. + +### Visiting TypeScript nodes + +When working with TypeScript nodes in a class property processor or a custom TypeScript node, it might be necessary to +visit and alter nodes in the tree. The `Visitor` class can be used to visit such a tree of TypeScript +nodes. + +The visitor will start in a node and then traverse the tree of TypeScript nodes, it is possible to register a `before` +and `after` callback for each node it visits. The `before` callback is called before visiting the children of a node and +the `after` callback is called after visiting the children of a node. + +```php +use Spatie\TypeScriptTransformer\Visitor\Visitor; + +Visitor::create() + ->before(function (TypeScriptNode $node){ + echo 'Before visiting ' . $node::class . PHP_EOL; + }) + ->after(function (TypeScriptNode $node) { + echo 'After visiting ' . $node::class . PHP_EOL; + }) + ->execute($rootNode); +``` + +When running the visitor on the following node: + +```php +$rootNode = new TypeScriptUnion([ + new TypeScriptString(), + new TypeScriptNumber(), +]); +``` + +The output will be (redacted for readability): + +``` +Before visiting TypeScriptUnion +Before visiting TypeScriptString +After visiting TypeScriptString +Before visiting TypeScriptNumber +After visiting TypeScriptNumber +After visiting TypeScriptUnion +``` + +By default, the visitor will visit the tree of nodes and run the callback on each node within the tree. It is possible +to limit the types of nodes the callback runs on: + +```php +Visitor::create() + ->after(function (TypeScriptUnion $node, [TypeScriptUnion::class]) { + // Do something with TypeScriptUnion nodes + }) + ->execute($rootNode); +``` + +When not returning a TypeScript node from the callback, the visitor will continue traversing the tree. It is possible to +replace a node in the tree like this: + +```php +use Spatie\TypeScriptTransformer\Visitor\VisitorOperation; + +Visitor::create() + ->after(function (TypeScriptUnion $node, [TypeScriptUnion::class]) { + if(count($node->types) === 1) { + return VisitorOperation::replace(array_values($node->types)[0]); + } + }) + ->execute($rootNode); +``` + +The visitor above will replace all union nodes with a single type with that type. + +It is also possible to remove a node from the tree: + +```php +Visitor::create() + ->after(function (TypeScriptString $node, [TypeScriptString::class]) { + return VisitorOperation::remove(); + }) + ->execute($rootNode); +``` + +### Hooking into TypeScript transformer + +Every time the TypeScript transformer is executed, it will go through a series of steps, it is possible to run a visitor +in between some of these steps. + +The steps look as following: + +1. Running of the TypeProviders creating a collection of Transformed types +2. Possible hooking point: `providedVisitorHook` +3. Connecting references between Transformed types +4. Possible hooking point: `connectedVisitorHook` +5. Create a collection of WriteableFiles +6. Write those files to disk +7. Format the files + +The two hooking points above can be used to run a visitor on the collection of Transformed types: + +```php +use Spatie\TypeScriptTransformer\Visitor\VisitorClosureType; + +$config->providedVisitorHook( + fn(TransformedCollection $collection) => Visitor::create()->execute($collection), + [TypeScriptUnion::class], + VisitorClosureType::Before +); + +$config->connectedVisitorHook( + fn(TransformedCollection $collection) => Visitor::create()->execute($collection), + [TypeScriptUnion::class], + VisitorClosureType::Before +); +``` + +Running visitors as an after hook is also possible: + +```php +$config->providedVisitorHook( + fn(TransformedCollection $collection) => Visitor::create()->execute($collection), + [TypeScriptUnion::class], + VisitorClosureType::After +); + +$config->connectedVisitorHook( + fn(TransformedCollection $collection) => Visitor::create()->execute($collection), + [TypeScriptUnion::class], + VisitorClosureType::After +); +``` + +### Building your own Writer + +Writers are responsible for writing out the TypeScript types, the package comes with three writers: + +- `NamespaceWriter`: Writes all types to a single TypeScript file with namespaces +- `ModuleWriter`: Writes all types to a file per namespace +- `FlatWriter`: Writes all types to a single TypeScript file without namespaces + +It is possible to create your own writer by implementing the `Writer` interface: + +```php +use Spatie\TypeScriptTransformer\Support\WriteableFile; + +interface Writer +{ + /** @return array */ + public function write( + TransformedCollection $collection, + ReferenceMap $referenceMap, + ): void; +} +``` + +In the end the `write` method should return an array of `WriteableFile` objects, these objects contain the TypeScript +code and the location where it should be written to. + +In the writer you should loop over each `Transformed` object in the collection, decide in which file it should be stored +and transform the TypeScript node to a string: + +```php +foreach ($collection as $transformed) { + $output .= $transformed->prepareForWrite()->write($writingContext) . PHP_EOL; +} +``` + +The `prepareForWrite` method will make sure that a TypeScript node is exported when required. The `write` method will +transform the TypeScript node to a string and requires a `WritingContext` object with information about the writing +context. + +For now the `WritingContext` consists of a Closure returning a string referencing other TypeScript types. We recommend +you to take a look at the `FlatWriter` to see how this is implemented. + +## Testing + +```bash composer test ``` @@ -81,9 +1286,9 @@ Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed re Please see [CONTRIBUTING](https://github.com/spatie/.github/blob/main/CONTRIBUTING.md) for details. -## Security +## Security Vulnerabilities -If you've found a bug regarding security please mail [security@spatie.be](mailto:security@spatie.be) instead of using the issue tracker. +Please review [our security policy](../../security/policy) on how to report security vulnerabilities. ## Credits diff --git a/UPGRADE.md b/UPGRADE.md index ba195dc..89aefb1 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1,13 +1,44 @@ -# Upgrading to v2 +# Upgrading + +Because there are many breaking changes an upgrade is not that easy. There are many edge cases this guide does not +cover. We accept PRs to improve this guide. + +## Upgrading to v3 + +Version 3 is a complete rewrite of the package. That's why writing an upgrade guide is not that easy. The best way to +upgrade is to start reading the new docs and try to implement the new features. + +A few noticeable changes are: + +- Laravel installs now need to configure the package in a service provider instead of config file +- The package requires PHP 8.2 +- If you're using Laravel, v10 is minimally required +- Collectors were removed in favour of Transformers which decide whether a type should be transformed or not +- The transformer should now return a `Transformed` object when it can transform a type +- The transformer interface now should return `Untransformable` when it cannot transform the type +- The `DtoTransformer` was removed in favour of a more flexible transformer system where you can create your own transformers +- The `EnumTransformer` was rewritten to allow multiple types of enums to be transformed and multiple output structures +- All other enum transformers were removed +- The concept of `TypeProcessors` was removed, `ClassPropertyProcessor` is a kinda replacement for this +- The TypeReflectors were removed +- Support for inline types was removed +- If you were implementing your own attributes, you should now implement the `TypeScriptTypeAttributeContract` interface instead of `TypeScriptTransformableAttribute` +- The `RecordTypeScriptType` attribute was removed since deduction of these kinds of types is now done by the transformer +- The `TypeScriptTransformer` attribute was removed +- If you were implementing your own `Formatter`, please update the `format` method to now work on an array of files + +And so much more. Please read the docs for more information. + +## Upgrading to v2 - The package is now PHP 8 only - The `ClassPropertyProcessor` interface was renamed to `TypeProcessor` and now takes a union of reflection objects - In the config: - - `searchingPath` was renamed to `autoDiscoverTypes` - - `classPropertyReplacements` was renamed to `defaultTypeReplacements` + - `searchingPath` was renamed to `autoDiscoverTypes` + - `classPropertyReplacements` was renamed to `defaultTypeReplacements` - Collectors now only have one method: `getTransformedType` which should - - return `null` when the collector cannot find a transformer - - return a `TransformedType` from a suitable transformer + - return `null` when the collector cannot find a transformer + - return a `TransformedType` from a suitable transformer - Transformers now only have one method: `transform` which should - return `null` when the transformer cannot transform the class - return a `TransformedType` if it can transform the class @@ -19,6 +50,6 @@ Laravel - In the Laravel config: - `searching_path` is renamed to `auto_discover_types` - `class_property_replacements` is renamed to `default_type_relacements` - - `writer` and `formatter` were added + - `writer` and `formatter` were added - You should replace the `DefaultCollector::class` with the `DefaultCollector::class` - It is not possible anymore to convert one file to TypeScript via command diff --git a/composer.json b/composer.json index 53844ad..6bec3bf 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "spatie/typescript-transformer", - "description": "Transform your PHP structures to TypeScript types", + "description": "This is my package typescript-transformer", "keywords": [ "spatie", "typescript-transformer" @@ -11,48 +11,65 @@ { "name": "Ruben Van Assche", "email": "ruben@spatie.be", - "homepage": "https://spatie.be", "role": "Developer" } ], "require": { - "php": "^8.0", - "nikic/php-parser": "^4.13", - "phpdocumentor/type-resolver": "^1.6.2", - "symfony/process": "^5.2|^6.0" + "php": "^8.2", + "illuminate/contracts": "^10.0|^11.0", + "phpstan/phpdoc-parser": "^1.13", + "roave/better-reflection": "^6.41", + "spatie/file-system-watcher": "^1.1", + "spatie/laravel-package-tools": "^1.14.0", + "spatie/php-structure-discoverer": "^2.2", + "spatie/temporary-directory": "^2.1" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^2.18", - "larapack/dd": "^1.1", - "myclabs/php-enum": "^1.7", - "pestphp/pest": "^1.22", - "phpunit/phpunit": "^9.0", - "spatie/data-transfer-object": "^2.0", - "spatie/enum": "^3.0", - "spatie/pest-plugin-snapshots": "^1.1", - "spatie/temporary-directory": "^1.2|^2.0", - "vimeo/psalm": "^4.2" + "friendsofphp/php-cs-fixer": "^3.0", + "laravel/pint": "^1.0", + "nunomaduro/collision": "^7.9", + "nunomaduro/larastan": "^2.0.1", + "orchestra/testbench": "^8.0|^9.0", + "pestphp/pest": "^2.0", + "pestphp/pest-plugin-arch": "^2.0", + "pestphp/pest-plugin-laravel": "^2.0", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan-deprecation-rules": "^1.0", + "phpstan/phpstan-phpunit": "^1.0", + "spatie/laravel-ray": "^1.26", + "spatie/pest-plugin-snapshots": "^2.1", + "spatie/ray": "^1.41", + "spatie/laravel-data": "^4.0" }, "autoload": { "psr-4": { - "Spatie\\TypeScriptTransformer\\": "src" + "Spatie\\TypeScriptTransformer\\": "src/" } }, "autoload-dev": { "psr-4": { - "Spatie\\TypeScriptTransformer\\Tests\\": "tests" + "Spatie\\TypeScriptTransformer\\Tests\\": "tests/" } }, "scripts": { + "post-autoload-dump": "@php ./vendor/bin/testbench package:discover --ansi", + "analyse": "vendor/bin/phpstan analyse", "test": "vendor/bin/pest", "test-coverage": "vendor/bin/pest --coverage", - "psalm": "./vendor/bin/psalm -c psalm.xml", - "format": "./vendor/bin/php-cs-fixer fix --allow-risky=yes" + "format": "vendor/bin/pint" }, "config": { "sort-packages": true, "allow-plugins": { - "pestphp/pest-plugin": true + "pestphp/pest-plugin": true, + "phpstan/extension-installer": true + } + }, + "extra": { + "laravel": { + "providers": [ + "Spatie\\TypeScriptTransformer\\Laravel\\TypeScriptTransformerServiceProvider" + ] } }, "minimum-stability": "dev", diff --git a/docs/_index.md b/docs/_index.md deleted file mode 100755 index f12b7d8..0000000 --- a/docs/_index.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -title: v2 -slogan: Convert PHP types to TypeScript -githubUrl: https://github.com/spatie/typescript-transformer -branch: main ---- diff --git a/docs/about-us.md b/docs/about-us.md deleted file mode 100755 index 8c5f6c1..0000000 --- a/docs/about-us.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -title: About us -weight: 8 ---- - -[Spatie](https://spatie.be) is a webdesign agency based in Antwerp, Belgium. - -Open source software is used in all projects we deliver. Laravel, Nginx, Ubuntu are just a few of the free pieces of software we use every single day. For this, we are very grateful. -When we feel we have solved a problem in a way that can help other developers, we release our code as open source software [on GitHub](https://spatie.be/opensource). - -These typescript-transformer and laravel-typescript-transformer packages were made by [Ruben Van Assche](https://github.com/rubenvanassche). There are many other contributors for the [typescript-transformer](https://github.com/spatie/typescript-transformer/graphs/contributors) and [laravel-typescript-transformer](https://github.com/spatie/laravel-typescript-transformer/graphs/contributors) package who devoted time and effort to make this package better. diff --git a/docs/changelog.md b/docs/changelog.md deleted file mode 100755 index dfb9497..0000000 --- a/docs/changelog.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -title: Changelog -weight: 7 ---- - -All notable changes to the `typescript-transformer` package will be documented in [the changelog on GitHub](https://github.com/spatie/typescript-transformer/blob/master/CHANGELOG.md). - -Changes to the `laravel-typescript-transformer` package are documented [here](https://github.com/spatie/laravel-typescript-transformer/blob/master/CHANGELOG.md). diff --git a/docs/dtos/_index.md b/docs/dtos/_index.md deleted file mode 100755 index 2a08fa9..0000000 --- a/docs/dtos/_index.md +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: Transforming PHP classes -weight: 3 ---- diff --git a/docs/dtos/transforming-dtos.md b/docs/dtos/transforming-dtos.md deleted file mode 100644 index 1253d32..0000000 --- a/docs/dtos/transforming-dtos.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -title: Customization -weight: 2 ---- - -The package provides a `DtoTransformer` out of the box. This transformer will convert all public non-static properties of a class to TypeScript types. - -For the Laravel, a special `DtoTransformer` was written with some extra Laravel niceties. You can find this transformer in the [spatie/laravel-typescript-transformer](https://github.com/spatie/laravel-typescript-transformer) package. - -This transformer was built to be extended for specific use cases: - -**canTransform** - -returns a boolean whether the transformer can transform the class - -**transformProperties** - -Takes a collection of reflection properties and transforms them into TypeScript - -**transformMethods** - -Takes a collection of methods and transforms them into TypeScript (disabled by default) - -**transformExtra** - -Allows you to add extra definitions to the current type - -**typeProcessors** - -Initiates the type processors that will run in the transformer - -**resolveProperties** - -Collects the properties that will be transformed diff --git a/docs/dtos/typing-properties.md b/docs/dtos/typing-properties.md deleted file mode 100644 index 721201e..0000000 --- a/docs/dtos/typing-properties.md +++ /dev/null @@ -1,279 +0,0 @@ ---- -title: Typing properties -weight: 1 ---- - -Let's take a look at how we can type individual properties of a PHP class. - -## Using PHP's built-in typed properties - -It's possible to use typed properties in a class. This package makes these types an A-class citizen. - -```php -class Dto -{ - public string $string; - - public int $integer; - - public float $float; - - public bool $bool; - - public array $array; - - public mixed $mixed; -} -``` - -It is also possible to use nullable types: - -```php -class Dto -{ - public ?string $string; -} -``` - -You can even use these union types: - -```php -class Dto -{ - public float|int $float_or_int; -} -``` - -Or use other types that can be replaced: - -```php -class Dto -{ - public DateTime $datetime; -} -``` - -## Using attributes - -You can use one of the two attributes provided by the package to transform them to TypeScript directly, more information about this [here](https://spatie.be/docs/typescript-transformer/v2/usage/annotations#using-typescript-within-php). - -## Using docblocks - -You can also use docblocks to type properties. You can find a more detailed overview of this [here](https://docs.phpdoc.org/latest/guides/types.html). While PHP's built-in typed properties are fine, docblocks allow for a bit more flexibility: - -```php -class Dto -{ - /** @var string */ - public $string; - - /** @var int */ - public $integer; - - /** @var float */ - public $float; - - /** @var bool */ - public $bool; - - /** @var array */ - public $array; - - /** @var array|string */ - public $arrayThatMightBeAString; -} -``` - -It is also possible to use nullable types in docblocks: - -```php -class Dto -{ - /** @var ?string */ - public $string; -} -``` - -And add types for your (custom) objects: - - -```php -class Dto -{ - /** @var \DateTime */ - public $dateTime; -} -``` - -Note: always use the fully qualified class name (FQCN). At this moment, the package cannot determine imported classes used in a docblock: - -```php -use App\DataTransferObjects\UserData; - -class Dto -{ - /** @var \App\DataTransferObjects\UserData */ - public $userData; // FCCN: this will work - - /** @var UserData */ - public $secondUserData; // Won't work, class import is not detected -} -``` - -It's also possible to add compound types: - -```php -class Dto -{ - /** @var string|int|bool|null */ - public $compound; -} -``` - -Or these unusual PHP specific types: - -```php -class Dto -{ - /** @var mixed */ - public $mixed; // transforms to `any` - - /** @var scalar */ - public $scalar; // transforms to `string|number|boolean` - - /** @var void */ - public $void; // transforms to `never` -} -``` - -You can even reference the object's own type: - -```php -class Dto -{ - /** @var self */ - public $self; - - /** @var static */ - public $static; - - /** @var $this */ - public $void; -} -``` - -These will all transform to a `Dto` TypeScript type. - -### Transforming arrays - -Arrays in PHP and TypeScript (JavaScript) are entirely different concepts. This poses a couple of problems we'll address. A PHP array is a multi-use storage/memory structure. In TypeScript, a PHP array can be represented both as an `Array` and as an `Object` with specified keys. - -Depending on how your annotations are written, the package will output either an `Array` or `Object`. Let's have a look at some examples that will transform into an `Array` type: - -```php -class Dto -{ - /** @var \DateTime[] */ - public $array; - - /** @var array<\DateTime> */ - public $another_array; - - /** @var array */ - public $you_probably_wont_write_this; -} -``` - -You can type objects as such: - -```php -class Dto -{ - /** @var array */ - public $object_with_string_keys; - - /** @var array */ - public $object_with_int_keys; -} -``` - -## Combining regular types and docblocks - -Whenever a property has a docblock, that docblock will be used to type the property. The 'real' PHP type will be omitted. - -If the property is nullable and has a docblock that isn't nullable, then the package will make the TypeScript type nullable. - -## Optional types - -You can make certain properties of a DTO optional in TypeScript as such: - -```php -class DataObject extends Data -{ - public function __construct( - #[Optional] - public int $id, - public string $name, - ) - { - } -} -``` - -This will be transformed into: - -```tsx -{ - id? : number; - name : string; -} -``` - -You can also transform all properties in a class to optional, by adding the attribute to the class: - -```php -#[Optional] -class DataObject extends Data -{ - public function __construct( - public int $id, - public string $name, - ) - { - } -} -``` - -Now all properties will be optional: - -```tsx -{ - id? : number; - name? : string; -} -``` - -## Hidden types - -You can make certain properties of a DTO hidden in TypeScript as such: - -```php -class DataObject extends Data -{ - public function __construct( - public int $id, - #[Hidden] - public string $hidden, - ) - { - } -} -``` - -This will be transformed into: - -```tsx -{ - id : number; -} -``` diff --git a/docs/images/header.jpg b/docs/images/header.jpg deleted file mode 100755 index 62f2604..0000000 Binary files a/docs/images/header.jpg and /dev/null differ diff --git a/docs/installation.md b/docs/installation.md deleted file mode 100755 index 29609d7..0000000 --- a/docs/installation.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -title: Installation -weight: 3 ---- - -## Basic installation - -You can install this package via composer: - -```bash -composer require spatie/typescript-transformer -``` - -We also created a Laravel specific package, you can find the installation instructions: [here](https://docs.spatie.be/typescript-transformer/v2/laravel/installation-and-setup/). diff --git a/docs/introduction.md b/docs/introduction.md deleted file mode 100755 index 2c250ee..0000000 --- a/docs/introduction.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -title: Introduction -weight: 1 ---- - -This package allows you to convert PHP classes to TypeScript. - - -This class... - -```php -/** @typescript */ -class User -{ - public int $id; - public string $name; - public ?string $address; -} -``` - -... will be converted to this TypeScript type: - -```ts -export type User = { - id: number; - name: string; - address: string | null; -} -``` - -Here's another example. - -```php -class Languages extends Enum -{ - const TYPESCRIPT = 'typescript'; - const PHP = 'php'; -} -``` - -The `Languages` enum will be converted to: - -```tsx -export type Languages = 'typescript' | 'php'; -``` diff --git a/docs/laravel/_index.md b/docs/laravel/_index.md deleted file mode 100755 index ecad49e..0000000 --- a/docs/laravel/_index.md +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: Laravel -weight: 4 ---- diff --git a/docs/laravel/executing-the-transform-command.md b/docs/laravel/executing-the-transform-command.md deleted file mode 100755 index 5ce42d8..0000000 --- a/docs/laravel/executing-the-transform-command.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -title: Executing the transform command -weight: 2 ---- - -After configuring the package in the `typescript-transformer` config file, you can run this command to write the typescript output file: - -```bash -php artisan typescript:transform -``` - -## Command options - -There are some extra commands you can use when running the command. It is also possible to transform classes in a specified path: - -```bash -php artisan typescript:transform --path=app/Enums -``` - -Or you can define another output file than the default one: - -```bash -php artisan typescript:transform --output=types.d.ts -``` - -This file will be stored in the resource's path of your Laravel application. - -It is also possible to automatically format the generated TypeScript with Prettier, ESLint, or a custom formatter: - -```bash -php artisan typescript:transform --format -``` diff --git a/docs/laravel/installation-and-setup.md b/docs/laravel/installation-and-setup.md deleted file mode 100755 index df224ee..0000000 --- a/docs/laravel/installation-and-setup.md +++ /dev/null @@ -1,100 +0,0 @@ ---- -title: Installation and setup -weight: 1 ---- - -## Basic installation - -You can install this package via composer: - -```bash -composer require spatie/laravel-typescript-transformer -``` - -The package will automatically register a service provider. - -You can publish the config file with: - -```bash -php artisan vendor:publish --provider="Spatie\LaravelTypeScriptTransformer\TypeScriptTransformerServiceProvider" -``` - -This is the default content of the config file: - -```php - [ - app_path() - ], - - /* - * Collectors will search for classes in the `auto_discover_types` paths and choose the correct - * transformer to transform them. By default, we include a DefaultCollector which will search - * for @typescript annotated and ![TypeScript] attributed classes to transform. - */ - - 'collectors' => [ - Spatie\TypeScriptTransformer\Collectors\DefaultCollector::class, - ], - - /* - * Transformers take PHP classes(e.g., enums) as an input and will output - * a TypeScript representation of the PHP class. - */ - - 'transformers' => [ - Spatie\LaravelTypeScriptTransformer\Transformers\SpatieStateTransformer::class, - Spatie\TypeScriptTransformer\Transformers\SpatieEnumTransformer::class, - Spatie\TypeScriptTransformer\Transformers\DtoTransformer::class, - ], - - /* - * In your classes, you sometimes have types that should always be replaced - * by the same TypeScript representations. For example, you can replace a - * Datetime always with a string. You define these replacements here. - */ - - 'default_type_replacements' => [ - DateTime::class => 'string', - DateTimeImmutable::class => 'string', - Carbon\CarbonImmutable::class => 'string', - Carbon\Carbon::class => 'string', - ], - - /* - * The package will write the generated TypeScript to this file. - */ - - 'output_file' => resource_path('types/generated.d.ts'), - - /* - * When the package is writing types to the output file, a writer is used to - * determine the format. By default, this is the `TypeDefinitionWriter`. - * But you can also use the `ModuleWriter` or implement your own. - */ - - 'writer' => Spatie\TypeScriptTransformer\Writers\TypeDefinitionWriter::class, - - /* - * The generated TypeScript file can be formatted. We ship two formatters by - * default: a Prettier and an ESLint one. You can also implement your own. - * The generated TypeScript will not be formatted if none is configured. - */ - - 'formatter' => null, - - /* - * Enums can be transformed into types or native TypeScript enums, by default - * the package will transform them to types. - */ - - 'transform_to_native_enums' => false, -]; -``` diff --git a/docs/postcardware.md b/docs/postcardware.md deleted file mode 100755 index a019fe3..0000000 --- a/docs/postcardware.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -title: Postcardware -weight: 2 ---- - -You're free to use this package, but if it makes it to your production environment we highly appreciate you sending us a postcard from your hometown, mentioning which of our package(s) you are using. - -Our address is: Spatie, Kruikstraat 22 Box 12, 2018 Antwerp, Belgium. - -The best postcards will get published on the [open source section](https://spatie.be/en/opensource/postcards) on our website. diff --git a/docs/questions-and-issues.md b/docs/questions-and-issues.md deleted file mode 100755 index 3d41fd0..0000000 --- a/docs/questions-and-issues.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -title: Questions & issues -weight: 6 ---- - -Find yourself stuck using the package? Found a bug? Do you have general questions or suggestions for improving the backup package? Feel free to [create an issue on GitHub](https://github.com/spatie/typescript-transformer/issues), we'll try to address it as soon as possible. - -If you've found a bug regarding security please mail [freek@spatie.be](mailto:freek@spatie.be) instead of using the issue tracker. diff --git a/docs/transformers/_index.md b/docs/transformers/_index.md deleted file mode 100755 index 584509e..0000000 --- a/docs/transformers/_index.md +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: Writing transformers -weight: 2 ---- diff --git a/docs/transformers/getting-started.md b/docs/transformers/getting-started.md deleted file mode 100644 index 752bd1c..0000000 --- a/docs/transformers/getting-started.md +++ /dev/null @@ -1,122 +0,0 @@ ---- -title: Getting started -weight: 1 ---- - -A transformer is a class that implements the `Transformer` interface: - -```php -use Spatie\TypeScriptTransformer\Transformers\Transformer; - -class EnumTransformer implements Transformer -{ - public function transform(ReflectionClass $class, string $name): ?TransformedType - { - } -} -``` - -In the `transform` method, you should transform a PHP `ReflectionClass` into a `TransformedType`. This `TransformedType` will -include the TypeScript representation of the PHP class and some extra information. - -When a transformer cannot transform the given `ReflectionClass` then the method should return `null`, indicating that the transformer is not suitable for the type. - -### Creating transformed types - -A `TransformedType` always has three properties: the `ReflectionClass` of the type you're transforming, the name of the -type and, of course, the transformed TypeScript code: - -```php -TransformedType::create( - ReflectionClass $class, // The reflection class - string $name, // The name of the Type - string $transformed // The TypeScript representation of the class -); -``` - -For types that depend on other types a fourth argument can be passed to the `create` method: - -```php -TransformedType::create( - ReflectionClass $class, - string $name, - string $transformed, - MissingSymbolsCollection $missingSymbols -); -``` - -A `MissingSymbolsCollection` will contain references to other types. The package will replace these references with -correct TypeScript types. - -Consider the following class as an example: - -```php -/** @typescript **/ -class User extends DataTransferObject -{ - public string $name; - - public RoleEnum $role; -} -``` - -As you can see, it has a `RoleEnum` as a property, which looks like this: - -```php -/** @typescript **/ -class RoleEnum extends Enum -{ - const GUEST = 'guest'; - const ADMIN = 'admin'; -} -``` - -When transforming the `User` class we don't have any context or types for the `RoleEnum`. The transformer can register -this missing symbols for a property using the `MissingSymbolsCollection` as such: - -```php -$type = $missingSymbols->add(RoleEnum::class); // Will return {%RoleEnum::class%} -``` - -The `add` method will return a token that can be used in your transformed type. It's a link between the two types and -will later be replaced by the actual type implementation. - -When no type was found (for example: because it wasn't converted to TypeScript), the type will default to -TypeScript's `any` type. - -In the end, the package will produce the following output: - -```tsx -export type RoleEnum = 'guest' | 'admin'; - -export type User = { - name: string; - role: RoleEnum; -} -``` - -#### Inline types - -It is also possible to create an inline type. These types are replaced directly in other types. You can read more about -inline types [here](/docs/typescript-transformer/v2/usage/annotations#inlining-types). - -Inline types can be created like a regular `TransformedType` but they do not need a name: - -```php -TransformedType::createInline( - ReflectionClass $class, - string $transformed -); -``` - -When required you can also add a `MissingSymbolsCollection`: - -```php -TransformedType::createInline( - ReflectionClass $class, - string $transformed, - MissingSymbolsCollection $missingSymbols -); -``` - -When you create a new transformer, don't forget to add it to the list of transformers in your configuration! diff --git a/docs/transformers/type-processors.md b/docs/transformers/type-processors.md deleted file mode 100644 index 46b5429..0000000 --- a/docs/transformers/type-processors.md +++ /dev/null @@ -1,134 +0,0 @@ ---- -title: Type processors -weight: 3 ---- - -You can use type processors to change an entity's internal `Type` before it is transpiled into TypeScript. - -## Default type processors - -- `ReplaceDefaultsTypeProcessor` replaces some types defined in the configuration -- `DtoCollectionTypeProcessor` replaces `DtoCollections` from the `spatie/data-transfer-object` package with their - TypeScript equivalent - -Specifically for Laravel, we also include the following type processors in the Laravel package: - -- `LaravelCollectionTypeProcessor` handles Laravel's `Collection` classes like `array`s - -## Using type processors in your transformers - -When you're using the `TransformsTypes` [trait](https://github.com/spatie/typescript-transformer/blob/master/src/Transformers/TransformsTypes.php) in your transformer and use -the `reflectionToTypeScript` then you can additionally pass type processors: - -```php -$this->reflectionToTypeScript( - $reflection, - $missingSymbolsCollection, - new ReplaceDefaultsTypeProcessor(), - new DtoCollectionTypeProcessor(), - // and so on ... -); -``` - -## Writing type processors - -A class property processor is any class that implements the `ClassPropertyProcessor` interface: - -```php -class MyClassPropertyProcessor implements TypeProcessor -{ - public function process( - Type $type, - ReflectionProperty|ReflectionParameter|ReflectionMethod $reflection, - MissingSymbolsCollection $missingSymbolsCollection - ): ?Type - { - // Transform the types of the property - } -} -``` - -### Returning a type - -You can either return a PHPDocumenter type or a `TypeScriptType` instance for literal TypeScript types. - -Let's take a look at an example. With this type processor, it will convert each property type into a `string`. - -Using a `TypeScriptType`: - -```php -class MyClassPropertyProcessor implements TypeProcessor -{ - public function process( - Type $type, - ReflectionProperty|ReflectionParameter|ReflectionMethod $reflection, - MissingSymbolsCollection $missingSymbolsCollection - ): ?Type - { - return TypeScriptType::create('SomeGenericType'); - } -} -``` - -Or using a PHPDocumenter type: - -```php -class MyClassPropertyProcessor implements TypeProcessor -{ - public function process( - Type $type, - ReflectionProperty|ReflectionParameter|ReflectionMethod $reflection, - MissingSymbolsCollection $missingSymbolsCollection - ): ?Type - { - return new String_(); - } -} -``` - -You can find all the possible PHPDocumenter -types [here](https://github.com/phpDocumentor/TypeResolver/tree/1.x/src/Types). - -### Walking over types - -Since any type can exist of arrays, compound types, nullable types, and more, you'll sometimes need to walk (or loop) -over these types to specify types case by case. This can be done by including the `ProcessesTypes` trait into your type -processor. - -This trait will add a `walk` method that takes an initial type and closure. - -Let's say you have a compound type like `string|bool|int`. The `walk` method will run a `string`, `bool` and `int` type -through the closure. You can then decide a type to be returned for each type given to the closure. Finally, the updated -compound type will also be passed to the closure. - -You can remove a type by returning `null`. - -Let's take a look at an example where we only keep `string` types and remove any others: - -```php -class MyClassPropertyProcessor implements TypeProcessor -{ - use ProcessesTypes; - - public function process( - Type $type, - ReflectionProperty|ReflectionParameter|ReflectionMethod $reflection, - MissingSymbolsCollection $missingSymbolsCollection - ): ?Type - { - return $this->walk($type, function (Type $type) { - if ($type instanceof _String || $type instanceof Compound) { - return $type; - } - - return null; - }); - } -} -``` - -As you can see, we check in the closure if the type is a `string` or a `compound` type. If it is none of these two -types, we remove it by returning `null`. - -Why checking if the given type is a compound type? In the end, the compound type will be given to the closure. If we -removed it, the whole property could be removed from the TypeScript definition. diff --git a/docs/transformers/type-reflectors.md b/docs/transformers/type-reflectors.md deleted file mode 100644 index e2b2c0c..0000000 --- a/docs/transformers/type-reflectors.md +++ /dev/null @@ -1,79 +0,0 @@ ---- -title: Type reflectors -weight: 2 ---- - -Writing transformers can be complicated since there's a lot to keep in mind when trying to resolve the types within PHP -classes. - -TypeReflectors can help you with this. They will take a `ReflectionMethod`, `ReflectionProperty` -or `ReflectionParameter` and convert it into a `Type` which can be easily transpiled to TypeScript. - -A type reflector uses the following information to deduce a type: - -- attributes added to the PHP definition -- an annotation was added with the PHP definition -- the type is written in PHP, and if it is nullable - -It will use all this information and creates a `Type` object from -the [phpDocumentor/TypeResolver](https://github.com/phpDocumentor/TypeResolver) package, examples of such types are: - -- Array_ -- Boolean -- Compound -- Object_ -- Void -- [and many more](https://github.com/phpDocumentor/TypeResolver/tree/1.x/src/Types) - -These types can be easily transpiled to TypeScript. Let's take a look at an example: - -```php -class Properties{ - #[LiteralTypeScriptType('unknown')] - public $propertyWithAttribute; - - /** @var int */ - public $propertyWithAnnotation; - - public bool $propertyWithType; - - public ?string $propertyWithNullableType; -}; -``` - -We can now write a transformer that uses the `TransformsTypes` trait. This trait adds the `reflectionToTypeScript` method to your transformer, which takes a reflected entity and a missing symbols collection and transforms it to Typescript. - -```php - -class PropertyTransformer implements Transformer{ - use TransformsTypes; - - public function transform(ReflectionClass $class, string $name) : ?TransformedType - { - $missingSymbols = new MissingSymbolsCollection(); - - $properties = array_map( - fn(ReflectionProperty $reflection) => "{$reflection->name}: {$this->reflectionToTypeScript($reflection, $missingSymbols)};", - $class->getProperties() - ); - - return TransformedType::create( - $class, - $name, - '{'. join($properties) . '}', - $missingSymbols - ); - } -} -``` - -This transformer will transform the `Properties` class into: - -```tsx -{ - propertyWithAttribute: unknown; - propertyWithAnnotation: number; - propertyWithType: boolean; - propertyWithNullableType: ?string; -} -``` diff --git a/docs/under-the-hood.md b/docs/under-the-hood.md deleted file mode 100644 index 9396e46..0000000 --- a/docs/under-the-hood.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -title: Under the hood -weight: 5 ---- - -Reading this page is not required for knowing how to use the package. We recommend to first read through the other documentation and then come back to read this page. - -## Step 0: configuring the package - -In the package configuration a couple important values are defined: - -- the path where your PHP classes are stored, -- the file where the TypeScript definition will be written, -- the collectors that will find relevant PHP classes that can be transformed, -- and the transformers required to convert PHP to TypeScript. - -## Step 1: Collecting classes - -We start by iterating over the PHP classes in the specified directory and create a `ReflectionClass` for each class. If a collector can collect one of these classes, it will try to find a suitable transformer. - -For example, the `DefaultCollector` will collect each class with a `@typescript` annotation or a `#[TypeScript]` attribute and feed it to all the registered transformers to hopefully find a transformer that can generate a type definition for the class. - -## Step 2: Transforming classes - -We've created a set of classes and their suitable transformers in step 1. We're now going to transform these types to TypeScript. For the enum example, this is relatively simple, but for a complex data transfer object (DTO) this process is a bit more complicated. - -Each property of the DTO will be checked: does it have a PHP type and/or does it have an annotated type? The package creates a unified `Type` from this and feeds it to the type processors. These will transform the type or completely remove it from the DTO's TypeScript definition. - -A good example of a type processor is the `ReplaceDefaultsTypeProcessor`. This one will replace some default types you can define in the configuration with a TypeScript representation. For example transforming `DateTime` or `Carbon` types to `string`. - -DTO's often have properties that contain other DTO's, or even other custom types. This is why we'll also keep track of the missing symbols when transforming a DTO. -Let's say your DTO has a property that contains another DTO. At the moment of transformation, the package will not know how that other DTO should be transformed. We'll temporarily use a missing symbol that can be replaced by a reference to the correcty DTO type later. - -## Step 3: Replacing missing symbols - -The classes we started with in step 2 are now transformed into TypeScript definitions, although some types are still missing references to other types. Thanks to the missing symbols collections that each transformer constructed, we can replace these references with the correct type. - -If a reference cannot be replaced because it cannot be found the package will default to the `any` type, as it doesn't know how to reference it. - -It's recommended to try to avoid these `any` types as much as possible. - -## Step 4: persisting types - -Our set of transformed classes is now ready. All missing symbols are replaced, so it's time to write them out. A writer will take the entire set of transformed types and write them down into a TypeScript type defintion file you configured. - -## Step 5: formatting the output - -The package tries to output readable TypeScript code without adhering to any code style. Using tools like Prettier the output can be formatted in a code style of your choice. diff --git a/docs/usage/_index.md b/docs/usage/_index.md deleted file mode 100755 index 7700d36..0000000 --- a/docs/usage/_index.md +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: Usage -weight: 1 ---- diff --git a/docs/usage/annotations.md b/docs/usage/annotations.md deleted file mode 100644 index d5fc084..0000000 --- a/docs/usage/annotations.md +++ /dev/null @@ -1,280 +0,0 @@ ---- -title: Describing types -weight: 3 ---- - -PHP classes will only be converted to TypeScript when they are annotated, there are quite a few ways to do this, let's take a look. - -When using the `@typescript` annotation, the PHP class's name will be used as name for the TypeScript type: - -```php -/** @typescript */ -class Language extends Enum{ - const en = 'en'; - const nl = 'nl'; - const fr = 'fr'; -} -``` - -The package will produce the following TypeScript: - -```tsx -export type Language = 'en' | 'nl' | 'fr'; -``` - -It is also possible to use a PHP8 attribute like this: - -```php -#[TypeScript] -class Language extends Enum{ - const en = 'en'; - const nl = 'nl'; - const fr = 'fr'; -} -``` - -You can also give the type another name: - -```php -/** @typescript Talen **/ -class Language extends Enum{} -``` - -Which also can be done using attributes: - -```php -#[TypeScript('Talen')] -class Language extends Enum{} -``` - -Now the transformed TypeScript looks like this: - -```tsx -export type Talen = 'en' | 'nl' | 'fr'; -``` - -## Inlining types - -It is also possible to annotate types as an inline type. These types will not create a whole new TypeScript type but replace a type inline in another type. Let's create a class containing the `Language` enum: - -```php -/** @typescript **/ -class Post -{ - public string $name; - public Language $language; -} -``` - -The transformed version of a `Post` would look like this: - -```tsx -export type Language = 'en' | 'nl' | 'fr'; - -export type Post = { - name : string; - language : Language; -} -``` - -We could inline the `Language` enum as such: - -```php -/** - * @typescript - * @typescript-inline - */ -class Language extends Enum{} -``` - -Or using an attribute: - -```php -#[TypeScript] -#[InlineTypeScriptType] -class Language extends Enum{} -``` - -And now our transformed TypeScript would look like this: - -```ts -export type Post = { - name : string; - language : 'en' | 'nl' | 'fr'; -} -``` - -## Using TypeScript to write TypeScript - -It is possible to directly represent a type as TypeScript within your PHP code: - -```php -#[TypeScript] -#[LiteralTypeScriptType("string | null")] -class CustomString{} -``` - -Now when `Language` is being transformed, the TypeScript respresentation is used: - -```tsx -export type CustomString = string | null; -``` - -You can even provide an array of types: - -```php -#[TypeScript] -#[LiteralTypeScriptType([ - 'email' => 'string', - 'name' => 'string', - 'age' => 'number', -])] -class UserData{ - public $email; - public $name; - public $age; -} -``` - -This would transform to: - -```tsx -export type UserData = { - email: string; - name: string; - age: number; -}; -``` - -This attribute can also be used with properties in a class, for example: - -```php -#[TypeScript] -class Post -{ - public string $name; - - #[LiteralTypeScriptType("'en' | 'nl' | 'fr'")] - public Language $language; -} -``` - -## Using PHP types to write TypeScript - -When you have a very specific type you want to describe in PHP then you can use the `TypeScriptType` which can transform every type [phpdocumentor](https://www.phpdoc.org) can read. For example, let's say you have an array that always has the same keys as this one: - -```php -$user = [ - 'name' => 'Ruben Van Assche', - 'email' => 'ruben@spatie.be', - 'age' => 26, - 'language' => Language::nl() -]; -``` - -When we put that array as a property in a class: - -```php -#[TypeScript] -class UserRepository{ - public array $user; -} -``` - -The transformed type will look like this: - -```tsx -export type UserRepository = { - user: Array; -}; -``` - -We can do better than this, since we know the keys of the array: - -```php -use Spatie\TypeScriptTransformer\Attributes\TypeScript;#[TypeScript] -class UserRepository{ - #[TypeScriptType([ - 'name' => 'string', - 'email' => 'string', - 'age' => 'int', - 'language' => Language::class - ])] - public array $user; -} -``` - -Now the transformed TypeScript will look like this: - -```tsx -export type UserRepository = { - user: { - name: string; - email: string; - age: number; - language: 'en' | 'nl' | 'fr'; - }; -}; -``` - -As you can see, the package is smart enough to convert `Language::class` to an inline enum we defined earlier. - -## Generating `Record` types - -If you need to generate a `Record` type, you may use the `RecordTypeScriptType` attribute: - -```php -use Spatie\TypeScriptTransformer\Attributes\RecordTypeScriptType; - -class FleetData extends Data -{ - public function __construct( - #[RecordTypeScriptType(AircraftType::class, AircraftData::class)] - public readonly array $fleet, - ) { - } -} -``` - -This will generate a `Record` type with a key type of `AircraftType::class` and a value type of `AircraftData::class`: - -```ts -export type FleetData = { - fleet: Record -} -``` - -Additionally, if you need the value type to be an array of the specified type, you may set the third parameter of `RecordTypeScriptType` to `true`: - -```php -class FleetData extends Data -{ - public function __construct( - #[RecordTypeScriptType(AircraftType::class, AircraftData::class, array: true)] - public readonly array $fleet, - ) { - } -} -``` - -This will generate the following interface: - -```ts -export type FleetData = { - fleet: Record> -} -``` - -## Selecting a transformer - -Want to define a specific transformer for the file? You can use the following annotation: - -```php -/** - * @typescript - * @typescript-transformer \Spatie\TypeScriptTransformer\Transformers\SpatieEnumTransformer::class - */ -class Languages extends Enum{} -``` - -It is also possible to transform types without adding annotations. You can read more about it [here](https://spatie.be/docs/typescript-transformer/v2/usage/selecting-classes-using-collectors). diff --git a/docs/usage/formatters.md b/docs/usage/formatters.md deleted file mode 100644 index aeda7cb..0000000 --- a/docs/usage/formatters.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -title: Formatters -weight: 7 ---- - -This output file with all the transformed types can be formatted using tools like Prettier. We ship an ESLint and a Prettier formatter, which will run after the output file is generated. For instance, you can configure the Prettier formatter as such: - -```php -$config = TypeScriptTransformerConfig::create() - ->formatter(PrettierFormatter::class) - ... -``` - -You could also implement your own formatter by implementing the `Formatter` interface: - -```php -interface Formatter -{ - public function format(string $file): void; -} -``` - -Within the `format` method, a path to the output file is given, which should be formatted. diff --git a/docs/usage/general-overview.md b/docs/usage/general-overview.md deleted file mode 100644 index a4df7a8..0000000 --- a/docs/usage/general-overview.md +++ /dev/null @@ -1,567 +0,0 @@ ---- -title: General overview -weight: 1 ---- - -Let's look at a real-world use case of how the package can transform PHP types to TypeScript. We're not going to use the default Laravel resources because they cannot be typed. Instead, we're going to use the [spatie/data-transfer-object](https://github.com/spatie/data-transfer-object) package. - -Let's first create a `UserResource`: - -```php -class UserResource extends DataTransferObject implements Arrayable -{ - public ?int $age = null; - - public ?string $name = null; - - public ?string $email = null; - - public ?AddressResource $address = null; -} -``` - -Here is the code of `AddressResource`: - -```php -class AddressResource extends DataTransferObject implements Arrayable -{ - public ?string $street = null; - - public ?string $number = null; - - public ?string $city = null; - - public ?string $postal = null; - - public ?string $country = null; -} -``` - -Each property is nullable, so it's easy to send an empty instance to the front end where necessary. - -To easily convert a user to a `UserResource`, we're going to add a static `make` function to it. We'll also implement `Illuminate\Contracts\Support\Arrayable` so the resource can be converted to an array when sending it to the front end. This interface requires the object to have a `toArray` method. The implementation of the `toArray` method lives in the `DataTransferObject` base class, which will use the object's public properties. - -When applying the changes described, the `UserResource` will now look like this: - -```php -use Illuminate\Contracts\Support\Arrayable; - -class UserResource extends DataTransferObject implements Arrayable -{ - public ?int $age = null; - - public ?string $name = null; - - public ?string $email = null; - - public ?AddressResource $address = null; - - public static function make(User $user): self - { - return new static([ - 'age' => $user->age, - 'name' => "{$user->first_name} {$user->last_name}", - 'email' => $user->email, - 'address' => AddressResource::make($user->address ?? new Address()), - ]); - } -} -``` - -Let's also apply the same changes to the `AddressResource`. - -```php -class AddressResource extends DataTransferObject implements Arrayable -{ - public ?string $street = null; - - public ?string $number = null; - - public ?string $city = null; - - public ?string $postal = null; - - public ?string $country = null; - - public static function make(Address $address): self - { - return new self([ - 'street' => $address->street, - 'number' => $address->number, - 'city' => $address->city, - 'postal' => $address->postal, - 'country' => $address->country, - ]); - } -} -``` - -When using DTO's, it's impossible to assign a `string` to an `int` type. Another benefit is IDE completion. You can now construct your resource with all the information hinted by your IDE. - -## Using resources in your project - -Let's use the `UserResource` in a controller. - -```php -class UserController -{ - public function create() - { - return UserResource::make(new User()); - } - - public function update(User $user) - { - return UserResource::make($user); - } -} -``` - -## Transforming DTOs to TypeScript - -```php -/** @typescript */ -class UserResource extends DataTransferObject implements Arrayable -{ - // ... -} - -/** @typescript */ -class AddressResource extends DataTransferObject implements Arrayable -{ - // ... -} -``` - -With that annotation in place, we can generate the typescript equivalents by executing this command: - - -```bash -php artisan typescript:transform -``` - -Then we get the following output: - -```bash -+------------------------------------+------------------------------------+ -| PHP class | TypeScript entity | -+------------------------------------+------------------------------------+ -| App\Http\Resources\UserResource | App.Http.Resources.UserResource | -| App\Http\Resources\AddressResource | App.Http.Resources.AddressResource | -+------------------------------------+------------------------------------+ -Transformed 2 PHP types to TypeScript - -``` - -A new file was created in the `resources/js` directory of our application. `generated.ts` contains two types: - -```ts -namespace App.Http.Resources { - export type AddressResource = { - street: string | null; - number: string | null; - city: string | null; - postal: string | null; - country: string | null; - } - - export type UserResource = { - age: number | null; - name: string | null; - email: string | null; - address: App.Http.Resources.AddressResource | null; - } -} -``` - -These types can now be used in TypeScript code. Referencing a `UserResource` can now be done using `App.Http.Resource.UserResource`. - -## Using collectors to find resources - -Instead of manually adding `@typescript` to each class, we can use a [collector](https://spatie.be/docs/typescript-transformer/v2/usage/selecting-classes-using-collectors). - -Let's first create an abstract class Resource: - -```php -abstract class Resource extends DataTransferObject implements Arrayable -{ -} -``` - -Next, the `UserResource` and `AddressResource` should extend `Resource`: - -```php -class UserResource extends Resource -{ - // ... -} - - -class AddressResource extends Resource -{ - // ... -} -``` - -With that in place, we can create a collector that will process all classes that extend `Resource` - -```php -class ResourceCollector extends Collector -{ - public function shouldCollect(ReflectionClass $class): bool - { - return $class->isSubclassOf(Resource::class); - } - - public function getTransformedType(ReflectionClass $class): ?TransformedType - { - if(! $class->isSubclassOf(Resource::class)) - { - return null; - } - - $transformer = new DtoTransformer($this->config); - - return $transformer->transform( - $class, - Str::before($class->getShortName(), 'Resource') - ); - } -} -``` - -Finally, `ResourceCollector` should be added to the list of collectors in the configuration file `typescript-transformer.php`: - -```php - ... - - /* - * Collectors will search for classes in your `searching_path` and choose the correct - * transformer to transform them. By default, we include an AnnotationCollector - * which will search for @typescript annotated classes to transform. - */ - - 'collectors' => [ - Spatie\TypeScriptTransformer\Collectors\AnnotationCollector::class, - App\Support\TypeScriptTransformer\ResourceCollector::class, - ], - - ... -``` - -Now you can run `php artisan typescript:transform` to create the TypeScript definitions. - -### Using default type replacements - -You can specify to which TypeScript type a PHP type should be converted. - -Let's add a `$birthday` property to the `UserResource`, which is of type `Carbon`. - -```php -class UserResource extends DataTransferObject implements Arrayable -{ - public ?int $age = null; - - public ?string $name = null; - - public ?string $email = null; - - public ?AddressResource $address = null; - - public ?Carbon $birthday = null; - - public static function make(User $user): self - { - return new self([ - 'age' => $user->age, - 'name' => "{$user->first_name} {$user->last_name}", - 'email' => $user->email, - 'address' => AddressResource::make($user->address ?? new Address()), - 'birthday' => $user->birthday, - ]); - } -} -``` - -Since `Carbon` isn't a primitive type like a `string`, `int`, `bool`, `array`, we actually cannot send it directly to the front. Using the `Resource` class, we can convert the `Carbon` instance to a string: - -```php -abstract class Resource extends DataTransferObject implements Arrayable -{ - public function toArray(): array - { - return collect(parent::toArray())->map(function ($value) { - if ($value instanceof Carbon) { - return $value->toAtomString(); - } - - return $value; - })->toArray(); - } -} -``` - -This class will transform it to the `any` TypeScript type, but you can make it more specific. You can specify to which TypeScript type the PHP type should be converted to in the' typescript-transformer' config file. - -``` - ... - - 'default_type_replacements' => [ - // ... - Carbon::class => 'string', - ], - - ... -``` - -### Using transformer to manually convert a type - -In the previous section, we converted a `Carbon` type to a string. If you want to have fine-grained control over how a PHP type should be converted to a TypeScript type, you can use a `Transformer`. Let's convert `Carbon` to a type that has a day, month, and year. - -First, we need to create a PHP class that has the desired structure. - -```php -@typescript -class CustomDate -{ - private int $year; - - private int $month; - - private int $day; - - public function __construct(int $year, int $month, int $day) - { - $this->year = $year; - $this->month = $month; - $this->day = $day; - } - - public function get(): array - { - return [ - 'year' => $this->year, - 'month' => $this->month, - 'day' => $this->day, - 'is_today' => date('Y/m/d') === "{$this->year}/{$this->month}/{$this->day}" - ]; - } -} -``` - -Next, the `UserResource` needs to use the `CustomDate` type: - -```php -class UserResource extends Resource -{ - public ?int $age = null; - - public ?string $name = null; - - public ?string $email = null; - - public ?AddressResource $address = null; - - public ?CustomDate $birthday = null; - - public static function make(User $user): self - { - return new self([ - 'age' => $user->age, - 'name' => "{$user->first_name} {$user->last_name}", - 'email' => $user->email, - 'address' => AddressResource::make($user->address ?? new Address()), - 'birthday' => new CustomDate( - $user->birthday->year, - $user->birthday->month, - $user->birthday->day - ), - ]); - } -} -``` - -And the base `Resource` needs to be aware of the `CustomDate` as well. - -```php -abstract class Resource extends DataTransferObject implements Arrayable -{ - public function toArray(): array - { - return collect(parent::toArray()) - ->map(function ($value) { - if ($value instanceof CustomDate) { - return $value->get(); - } - - return $value; - }) - ->toArray(); - } -} -``` - -If we would run `php artisan typescript:transform` now, this would be the result. - -```ts -export type User = { - age: number | null; - name: string | null; - email: string | null; - address: App.Http.Resources.Address | null; - birthday: any | null; -} -``` - -That `any` does not describe our homemade `CustomDate` type. Let's fix that by using a `Transformer`: - -```php -class CustomDateTransformer implements Transformer -{ - public function transform(ReflectionClass $class, string $name): ?TransformedType - { - if(!$class->getName() === CustomDate::class) - { - return null; - } - - $type = << [ - App\Support\TypeScriptTransformer\CustomDateTransformer::class, - // ... - ], - - ... -``` - - - -Running `php artisan typescript:transform` will generate these TypeScript types: - -```ts -namespace App.Http.Resources { - export type Address = { - street: string | null; - number: string | null; - city: string | null; - postal: string | null; - country: string | null; - } - - export type User = { - age: number | null; - name: string | null; - email: string | null; - address: App.Http.Resources.Address | null; - birthday: App.Support.CustomDate | null; - } -} - -namespace App.Support { - export type CustomDate = { - year: number; - month: number; - day: number; - is_today: boolean; - } -} -``` - -If you don't need a separate `App.Support.CustomDate` type, you can choose to inline it in the types where it is used. To do that, use the `createInline` function in the `Transformer`. - -```php -class CustomDateTransformer implements Transformer -{ - public function transform(ReflectionClass $class, string $name): ?TransformedType - { - if(!$class->getName() === CustomDate::class) - { - return null; - } - - $type = << [ - // ... - CustomDate::class => TypeScriptType::create(<<autoDiscoverTypes(__DIR__ . '/src') - // list of transformers - ->transformers([MyclabsEnumTransformer::class]) - // file where TypeScript type definitions will be written - ->outputFile(__DIR__ . '/js/generated.d.ts'); -``` - -This is the minimal required configuration that should get you started. There are some more configuration options, but we'll go over these later in the documentation. - -Let's use this configuration to start the transformation process: - -```php -TypeScriptTransformer::create($config)->transform(); -``` - -That's it! Each class with a `@typescript` annotation or `#[TypeScript]` are now transformed to TypeScript if a suitable transformer can be found. - -## Laravel - -Are you using Laravel? Then you can use a Laravel config file, more info about that [here](https://docs.spatie.be/typescript-transformer/v2/laravel/installation-and-setup/). diff --git a/docs/usage/selecting-classes-using-collectors.md b/docs/usage/selecting-classes-using-collectors.md deleted file mode 100644 index f035e33..0000000 --- a/docs/usage/selecting-classes-using-collectors.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -title: Collectors -weight: 5 ---- - -In some cases, you'll want to transform classes without an attribute or annotation. For example, Laravel's API resources are almost always sent to the front and should always have a TypeScript definition ready to be used. - -Collectors allow you to specify which PHP classes should be transformed to TypeScript and what transformer should be used. - -The package comes out of the box with the pre-configured `DefaultCollector` to find and transform classes marked with the `@typescript` annotation and `#[TypeScript]` attributes. - -A collector is a class that extends the abstract `Collector` class and implements the `getTransformedType` method: - -```php -class EnumCollector extends Collector -{ - public function getTransformedType(ReflectionClass $class): ?TransformedType - { - } -} -``` - -The `getTransformedType` will return a `TransformedType` object if it can transform a `ReflectionClass` to TypeScript. When not possible, the method should return `null`. - -Don't forget to add the collector to your configuration: - -```php -$config = TypeScriptTransformerConfig::create() - ->collectors([EnumCollector::class]) - ... -``` - -Collectors will be checked in order if a perfect collector fit was found for a type. Then all the other collectors after that collector will be ignored for that type. - -## Difference between Collectors and Transformers - -Although the two concepts share a very similar interface, they are indeed different. - -A collector takes Types and gives them to a specific transformer that the collector decided. For example, the `DefaultCollector` will run a type through each transformer you've configured to find the right one. - -Collectors can also change names for specific Types. For example, a ResourceCollector could strip the Resource prefix of each class it collects. - -Transformers, on the other hand, transform types. They take a `ReflectionClass` and try to transform it to TypeScript. diff --git a/docs/usage/using-transformers.md b/docs/usage/using-transformers.md deleted file mode 100644 index 312dda4..0000000 --- a/docs/usage/using-transformers.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -title: Using transformers -weight: 4 ---- - -Transformers are the heart of the package. They take a PHP class and try to make a TypeScript definition out of it. - -## Default transformers - -The package comes with a few transformers out of the box: - -- `EnumTransformer`: this transforms a PHP 8.1 native enum -- `MyclabsEnumTransformer`: this transforms an enum from the [`myclabs\enum`](https://github.com/myclab/enum) package -- `SpatieEnumTransformer`: this transforms an enum from the [`spatie\enum`](https://github.com/spatie/enum) package -- `DtoTransformer`: a powerful transformer that transforms entire classes and their properties, you can read more about - it [here](/docs/typescript-transformer/v2/dtos/typing-properties) -- `InterfaceTransformer`: this transforms a PHP interface and its functions to a Typescript interface. If used, this - transformer should always be included before the `DtoTransformer`. - -[The laravel package](/docs/typescript-transformer/v2/laravel/installation-and-setup) has some extra transformers: - -- `SpatieStateTransformer`: this transforms a state from - the [`spatie\laravel-model-states`](https://github.com/spatie/laravel-model-status) package -- `DtoTransformer`: a more Laravel specific transformer based upon the default `DtoTransformer` - -There are also some packages with community transformers: - -- A [transformer](https://github.com/wt-health/laravel-enum-transformer) for `bensampo/laravel-enum` enums - -If you've written a transformer package, let us know, and we add it to the list! - -You should supply a list of transformers the package should use in your config. The order of transformers matters and can lead to unexpected results if in the wrong order. A PHP declaration (e.g. classes, enums) will go through each transformer and stop once a transformer is able to handle it; this is a problem if `DtoTransformer` is listed before an enum transformer since `DtoTransformer` will incorrectly handle an enum as a class and never allow `MyclabsEnumTransformer` to handle it. - -```php -$config = TypeScriptTransformerConfig::create() - ->transformers([MyclabsEnumTransformer::class, DtoTransformer::class]) - ... -``` - -### Transforming enums - -The package ships with three enum transformers out of the box, by default these enums are transformed to TypeScript types like this: - -```tsx -type Language = 'JS' | 'PHP'; -``` - -It is possible to transform them to native TypeScript enums by changing the config: - -```php -$config = TypeScriptTransformerConfig::create() - ->transformToNativeEnums() - ... -``` - -A transformed enum now looks like this: - -```tsx -enum Language {'JS' = 'JS', 'PHP' = 'PHP'}; -``` - -## Writing your own transformers - -We've added a whole section in the docs about [writing transformers](../transformers/getting-started). diff --git a/docs/usage/writers.md b/docs/usage/writers.md deleted file mode 100644 index 6b7f309..0000000 --- a/docs/usage/writers.md +++ /dev/null @@ -1,96 +0,0 @@ ---- -title: Writers -weight: 6 ---- - -When all types are transformed, the package will write them out in one big output file. A writer will determine how this output will look. - -You can configure a writer as such: - -```php -$config = TypeScriptTransformerConfig::create() - ->writer(TypeDefinitionWriter::class) - ... -``` - -By default, the `TypeDefinitionWriter` is used when no writer was configured. You can use one of the Writers shipped with the package out of the box or write your own one. - -## TypeDefinitionWriter - -The `TypeDefinitionWriter` will group types into namespaces that follow the structure of the PHP namespaces. - -Let's take a look at an example with two PHP classes: - -```php -namespace App\Enums; - -#[TypeScript] -class Language extends Enum -{ - public const nl = 'nl'; - public const en = 'en'; - public const fr = 'fr'; -} -``` - -and - -```php -namespace App\Models; - -#[TypeScript] -class User -{ - public string $name; - public \App\Enums\Language $language; -} -``` - -The written TypeScript will look like this: - -```tsx -namespace App.Enums { - export type Language = 'nl' | 'en' | 'fr'; -}; - -namespace App.Models { - export type User = { - name: string; - language: App.Enums.Language - }; -}; -``` - -## ModuleWriter - -The `ModuleWriter` will ignore namespaces and list all the types as individual modules. - -When we use the same classes from the previous example, the written TypeScript now looks like this: - -```tsx -export type Language = 'nl' | 'en' | 'fr'; -export type User = { - name: string; - language: Language -}; -``` - -## Building your own writer - -A writer is a class implementing the `Writer` interface: - -```php -interface Writer -{ - public function format(TypesCollection $collection): string; - - public function replacesSymbolsWithFullyQualifiedIdentifiers(): bool; -} -``` - -The `format` method takes a `TypesCollection` and outputs a string containing the TypeScript representation. - -In the `replacesSymbolsWithFullyQualifiedIdentifiers` method, a boolean is returned that indicates whether to use fully qualified identifiers when replacing symbols or not. - -The `TypeDefinitionWriter` uses fully qualified identifiers with a namespace, whereas the `ModuleWriter` doesn't. - diff --git a/lint-staged.config.js b/lint-staged.config.js new file mode 100644 index 0000000..33f947c --- /dev/null +++ b/lint-staged.config.js @@ -0,0 +1,3 @@ +module.exports = { + '**/*.php': ['php ./vendor/bin/php-cs-fixer fix --config .php-cs-fixer.dist.php --allow-risky=yes'], +}; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..9739ef6 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,835 @@ +{ + "name": "typescript-transformer", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "chokidar": "^3.5.3" + }, + "devDependencies": { + "husky": "^9.0.11", + "lint-staged": "^15.2.2", + "typescript": "^5.1.6" + } + }, + "node_modules/ansi-escapes": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-6.2.0.tgz", + "integrity": "sha512-kzRaCqXnpzWs+3z5ABPQiVke+iq0KXkHo8xiWV4RPTi5Yli0l97BEQuhXV1s7+aSU/fu1kUuxgS4MsQ0fRuygw==", + "dev": true, + "dependencies": { + "type-fest": "^3.0.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", + "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/binary-extensions": { + "version": "2.2.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/braces": { + "version": "3.0.2", + "license": "MIT", + "dependencies": { + "fill-range": "^7.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/chalk": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz", + "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==", + "dev": true, + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chokidar": { + "version": "3.5.3", + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ], + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/cli-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz", + "integrity": "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==", + "dev": true, + "dependencies": { + "restore-cursor": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz", + "integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==", + "dev": true, + "dependencies": { + "slice-ansi": "^5.0.0", + "string-width": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true + }, + "node_modules/commander": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "dev": true, + "engines": { + "node": ">=16" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/emoji-regex": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.3.0.tgz", + "integrity": "sha512-QpLs9D9v9kArv4lfDEgg1X/gN5XLnf/A6l9cs8SPZLRZR3ZkY9+kwIQTxm+fsSej5UMYGE8fdoaZVIBlqG0XTw==", + "dev": true + }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "dev": true + }, + "node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/fill-range": { + "version": "7.0.1", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.2.0.tgz", + "integrity": "sha512-2nk+7SIVb14QrgXFHcm84tD4bKQz0RxPuMT8Ag5KPOq7J5fEmAg0UbXdTOSHqNuHSU28k55qnceesxXRZGzKWA==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "dev": true, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "dev": true, + "engines": { + "node": ">=16.17.0" + } + }, + "node_modules/husky": { + "version": "9.0.11", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.0.11.tgz", + "integrity": "sha512-AB6lFlbwwyIqMdHYhwPe+kjOC3Oc5P3nThEoW/AaO2BX3vJDjWPFxYLxokUZOo6RNX20He3AaT8sESs9NJcmEw==", + "dev": true, + "bin": { + "husky": "bin.mjs" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", + "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "node_modules/lilconfig": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.0.0.tgz", + "integrity": "sha512-K2U4W2Ff5ibV7j7ydLr+zLAkIg5JJ4lPn1Ltsdt+Tz/IjQ8buJ55pZAxoP34lqIiwtF9iAvtLv3JGv7CAyAg+g==", + "dev": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/lint-staged": { + "version": "15.2.2", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-15.2.2.tgz", + "integrity": "sha512-TiTt93OPh1OZOsb5B7k96A/ATl2AjIZo+vnzFZ6oHK5FuTk63ByDtxGQpHm+kFETjEWqgkF95M8FRXKR/LEBcw==", + "dev": true, + "dependencies": { + "chalk": "5.3.0", + "commander": "11.1.0", + "debug": "4.3.4", + "execa": "8.0.1", + "lilconfig": "3.0.0", + "listr2": "8.0.1", + "micromatch": "4.0.5", + "pidtree": "0.6.0", + "string-argv": "0.3.2", + "yaml": "2.3.4" + }, + "bin": { + "lint-staged": "bin/lint-staged.js" + }, + "engines": { + "node": ">=18.12.0" + }, + "funding": { + "url": "https://opencollective.com/lint-staged" + } + }, + "node_modules/listr2": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-8.0.1.tgz", + "integrity": "sha512-ovJXBXkKGfq+CwmKTjluEqFi3p4h8xvkxGQQAQan22YCgef4KZ1mKGjzfGh6PL6AW5Csw0QiQPNuQyH+6Xk3hA==", + "dev": true, + "dependencies": { + "cli-truncate": "^4.0.0", + "colorette": "^2.0.20", + "eventemitter3": "^5.0.1", + "log-update": "^6.0.0", + "rfdc": "^1.3.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/log-update": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.0.0.tgz", + "integrity": "sha512-niTvB4gqvtof056rRIrTZvjNYE4rCUzO6X/X+kYjd7WFxXeJ0NwEFnRxX6ehkvv3jTwrXnNdtAak5XYZuIyPFw==", + "dev": true, + "dependencies": { + "ansi-escapes": "^6.2.0", + "cli-cursor": "^4.0.0", + "slice-ansi": "^7.0.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/is-fullwidth-code-point": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.0.0.tgz", + "integrity": "sha512-OVa3u9kkBbw7b8Xw5F9P+D/T9X+Z4+JruYVNapTjPYZYUznQ5YfWeFkOj606XYYW8yugTfC8Pj0hYqvi4ryAhA==", + "dev": true, + "dependencies": { + "get-east-asian-width": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/slice-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.0.tgz", + "integrity": "sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg==", + "dev": true, + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true + }, + "node_modules/micromatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", + "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "dev": true, + "dependencies": { + "braces": "^3.0.2", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "dev": true, + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/picomatch": { + "version": "2.3.1", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pidtree": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.0.tgz", + "integrity": "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==", + "dev": true, + "bin": { + "pidtree": "bin/pidtree.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/restore-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz", + "integrity": "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==", + "dev": true, + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor/node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/restore-cursor/node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true + }, + "node_modules/rfdc": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.3.1.tgz", + "integrity": "sha512-r5a3l5HzYlIC68TpmYKlxWjmOP6wiPJ1vWv2HeLhNsRZMrCkxeqxiHlQ21oXmQ4F3SiryXBHhAD7JZqvOJjFmg==", + "dev": true + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/slice-ansi": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", + "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^6.0.0", + "is-fullwidth-code-point": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/string-argv": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", + "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", + "dev": true, + "engines": { + "node": ">=0.6.19" + } + }, + "node_modules/string-width": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.1.0.tgz", + "integrity": "sha512-SEIJCWiX7Kg4c129n48aDRwLbFb2LJmXXFrWBG4NGaRtMQ3myKPKbwrD1BKqQn74oCoNMBVrfDEr5M9YxCsrkw==", + "dev": true, + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/type-fest": { + "version": "3.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-3.13.1.tgz", + "integrity": "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==", + "dev": true, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.1.6", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", + "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", + "dev": true, + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/yaml": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.4.tgz", + "integrity": "sha512-8aAvwVUSHpfEqTQ4w/KMlf3HcRdt50E5ODIQJBw1fQ5RL34xabzxtUlzTXVqc4rkZsPbvrXKWnABCD7kWSmocA==", + "dev": true, + "engines": { + "node": ">= 14" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..5914931 --- /dev/null +++ b/package.json @@ -0,0 +1,13 @@ +{ + "devDependencies": { + "husky": "^9.0.11", + "lint-staged": "^15.2.2", + "typescript": "^5.1.6" + }, + "dependencies": { + "chokidar": "^3.5.3" + }, + "scripts": { + "prepare": "husky" + } +} diff --git a/psalm.xml b/psalm.xml deleted file mode 100644 index 3e4e3d0..0000000 --- a/psalm.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - diff --git a/src/Actions/ConnectReferencesAction.php b/src/Actions/ConnectReferencesAction.php new file mode 100644 index 0000000..253a34d --- /dev/null +++ b/src/Actions/ConnectReferencesAction.php @@ -0,0 +1,69 @@ +visitor = $this->resolveVisitor(); + } + + /** + * @param TransformedCollection $collection + */ + public function execute(TransformedCollection $collection): void + { + foreach ($collection->onlyChanged() as $transformed) { + $metadata = [ + 'transformed' => $transformed, + 'collection' => $collection, + ]; + + $this->visitor->execute($transformed->typeScriptNode, $metadata); + } + } + + protected function resolveVisitor(): Visitor + { + return Visitor::create()->before(function (TypeReference $typeReference, array &$metadata) { + /** @var Transformed $currentTransformed */ + $currentTransformed = $metadata['transformed']; + + /** @var TransformedCollection $collection */ + $collection = $metadata['collection']; + + $foundTransformed = $collection->get($typeReference->reference); + + if ($foundTransformed === null) { + $currentTransformed->addMissingReference($typeReference->reference, $typeReference); + + $this->log->warning("Tried replacing reference to `{$typeReference->reference->humanFriendlyName()}` in `{$currentTransformed->reference->humanFriendlyName()}` but it was not found in the transformed types"); + + return; + } + + if (! array_key_exists($foundTransformed->reference->getKey(), $currentTransformed->references)) { + $currentTransformed->references[$foundTransformed->reference->getKey()] = []; + } + + $currentTransformed->references[$foundTransformed->reference->getKey()][] = $typeReference; + $foundTransformed->referencedBy[] = $currentTransformed->reference->getKey(); + + $typeReference->connect($foundTransformed); + + if (array_key_exists($foundTransformed->reference->getKey(), $currentTransformed->missingReferences)) { + unset($currentTransformed->missingReferences[$foundTransformed->reference->getKey()]); + } + }, [TypeReference::class]); + } +} diff --git a/src/Actions/DiscoverTypesAction.php b/src/Actions/DiscoverTypesAction.php new file mode 100644 index 0000000..0d87f53 --- /dev/null +++ b/src/Actions/DiscoverTypesAction.php @@ -0,0 +1,36 @@ + $directories + * + * @return array + */ + public function execute( + array $directories = [], + ): array { + $discovered = Discover::in(...$directories) + ->types( + DiscoveredStructureType::ClassDefinition, + DiscoveredStructureType::Enum, + DiscoveredStructureType::Interface + ) + ->get(); + + return array_values(array_filter(array_map(function (string $discovered) { + try { + return PhpClassNode::fromReflection(new ReflectionClass($discovered)); + } catch (\ReflectionException) { + return null; + } + }, $discovered))); + } +} diff --git a/src/Actions/ExecuteConnectedClosuresAction.php b/src/Actions/ExecuteConnectedClosuresAction.php new file mode 100644 index 0000000..b219957 --- /dev/null +++ b/src/Actions/ExecuteConnectedClosuresAction.php @@ -0,0 +1,35 @@ +visitor = Visitor::create()->closures(...$this->config->connectedVisitorClosures); + } + + /** + * @param TransformedCollection|Traversable $nodes + */ + public function execute( + TransformedCollection|Traversable $nodes, + ): void { + if (empty($this->config->providedVisitorClosures)) { + return; + } + + foreach ($nodes as $node) { + $this->visitor->execute($node->typeScriptNode); + } + } +} diff --git a/src/Actions/ExecuteProvidedClosuresAction.php b/src/Actions/ExecuteProvidedClosuresAction.php new file mode 100644 index 0000000..a6662fc --- /dev/null +++ b/src/Actions/ExecuteProvidedClosuresAction.php @@ -0,0 +1,35 @@ +visitor = Visitor::create()->closures(...$this->config->providedVisitorClosures); + } + + /** + * @param TransformedCollection|Traversable $nodes + */ + public function execute( + TransformedCollection|Traversable $nodes, + ): void { + if (empty($this->config->providedVisitorClosures)) { + return; + } + + foreach ($nodes as $node) { + $this->visitor->execute($node->typeScriptNode); + } + } +} diff --git a/src/Actions/FindClassNameFqcnAction.php b/src/Actions/FindClassNameFqcnAction.php new file mode 100644 index 0000000..bb1b320 --- /dev/null +++ b/src/Actions/FindClassNameFqcnAction.php @@ -0,0 +1,58 @@ + */ + protected static array $cache = []; + + public function __construct( + protected UseDefinitionsResolver $useDefinitionsResolver = new UseDefinitionsResolver() + ) { + } + + public function execute(PhpClassNode $node, string $className): ?string + { + $usages = $this->loadUsages($node); + + $className = $this->cleanupClassname($className); + + if ($usage = $usages->findForAlias($className)) { + return $this->cleanupClassname($usage->fcqn); + } + + if (! $node->inNamespace() && class_exists($className)) { + return $this->cleanupClassname($className); + } + + $guessedFqcn = "{$node->getNamespaceName()}\\{$className}"; + + if (class_exists($guessedFqcn)) { + return $this->cleanupClassname($guessedFqcn); + } + + return $className; + } + + protected function loadUsages(PhpClassNode $node): UsageCollection + { + $filename = $node->getFileName(); + + if (! array_key_exists($filename, static::$cache)) { + static::$cache[$filename] = $this->useDefinitionsResolver->execute($filename); + } + + return static::$cache[$filename]; + } + + protected function cleanupClassname( + string $classname + ): string { + return ltrim($classname, '\\'); + } +} diff --git a/src/Actions/FormatFilesAction.php b/src/Actions/FormatFilesAction.php new file mode 100644 index 0000000..0fec01b --- /dev/null +++ b/src/Actions/FormatFilesAction.php @@ -0,0 +1,28 @@ + $writtenFiles + */ + public function execute(array $writtenFiles): void + { + if ($this->config->formatter === null) { + return; + } + + $this->config->formatter->format( + array_map(fn (WriteableFile $writtenFile) => $writtenFile->path, $writtenFiles) + ); + } +} diff --git a/src/Actions/FormatTypeScriptAction.php b/src/Actions/FormatTypeScriptAction.php deleted file mode 100644 index 17e977f..0000000 --- a/src/Actions/FormatTypeScriptAction.php +++ /dev/null @@ -1,26 +0,0 @@ -config = $config; - } - - public function execute(): void - { - $formatter = $this->config->getFormatter(); - - if ($formatter === null) { - return; - } - - $formatter->format($this->config->getOutputFile()); - } -} diff --git a/src/Actions/ParseUserDefinedTypeAction.php b/src/Actions/ParseUserDefinedTypeAction.php new file mode 100644 index 0000000..577f040 --- /dev/null +++ b/src/Actions/ParseUserDefinedTypeAction.php @@ -0,0 +1,34 @@ +typeParser = new TypeParser($constExprParser); + } + + public function execute(string $type, ?PhpClassNode $node = null): TypeScriptNode + { + return $this->transpilePhpStanTypeToTypeScriptNodeAction->execute( + $this->typeParser->parse(new TokenIterator($this->lexer->tokenize($type))), + $node, + ); + } +} diff --git a/src/Actions/PersistTypesCollectionAction.php b/src/Actions/PersistTypesCollectionAction.php deleted file mode 100644 index 271638d..0000000 --- a/src/Actions/PersistTypesCollectionAction.php +++ /dev/null @@ -1,40 +0,0 @@ -config = $config; - } - - public function execute(TypesCollection $collection): void - { - $this->ensureOutputFileExists(); - - $writer = $this->config->getWriter(); - - (new ReplaceSymbolsInCollectionAction())->execute( - $collection, - $writer->replacesSymbolsWithFullyQualifiedIdentifiers() - ); - - file_put_contents( - $this->config->getOutputFile(), - $writer->format($collection) - ); - } - - protected function ensureOutputFileExists(): void - { - if (! file_exists(pathinfo($this->config->getOutputFile(), PATHINFO_DIRNAME))) { - mkdir(pathinfo($this->config->getOutputFile(), PATHINFO_DIRNAME), 0755, true); - } - } -} diff --git a/src/Actions/ProvideTypesAction.php b/src/Actions/ProvideTypesAction.php new file mode 100644 index 0000000..8aa2462 --- /dev/null +++ b/src/Actions/ProvideTypesAction.php @@ -0,0 +1,33 @@ +config->typeProviders as $typeProvider) { + $typeProvider = $typeProvider instanceof TypesProvider + ? $typeProvider + : new $typeProvider(); + + $typeProvider->provide( + $this->config, + $collection + ); + } + + return $collection; + } +} diff --git a/src/Actions/ReplaceSymbolsInCollectionAction.php b/src/Actions/ReplaceSymbolsInCollectionAction.php deleted file mode 100644 index 9c76f0a..0000000 --- a/src/Actions/ReplaceSymbolsInCollectionAction.php +++ /dev/null @@ -1,19 +0,0 @@ -transformed = $replaceSymbolsInTypeAction->execute($type); - } - - return $collection; - } -} diff --git a/src/Actions/ReplaceSymbolsInTypeAction.php b/src/Actions/ReplaceSymbolsInTypeAction.php deleted file mode 100644 index 1260594..0000000 --- a/src/Actions/ReplaceSymbolsInTypeAction.php +++ /dev/null @@ -1,61 +0,0 @@ -collection = $collection; - $this->withFullyQualifiedNames = $withFullyQualifiedNames; - } - - public function execute(TransformedType $type, array $chain = []): string - { - if (in_array($type->getTypeScriptName(), $chain)) { - $chain = array_merge($chain, [$type->getTypeScriptName()]); - - throw CircularDependencyChain::create($chain); - } - - foreach ($type->missingSymbols->all() as $missingSymbol) { - $this->collection[$type] = $this->replaceSymbol($missingSymbol, $type, $chain); - } - - return $type->transformed; - } - - protected function replaceSymbol(string $missingSymbol, TransformedType $type, array $chain): TransformedType - { - $found = $this->collection[$missingSymbol]; - - if ($found === null) { - $type->replaceSymbol($missingSymbol, 'any'); - - return $type; - } - - if (! $found->isInline) { - $type->replaceSymbol($missingSymbol, $found->getTypeScriptName($this->withFullyQualifiedNames)); - - return $type; - } - - $transformed = $this->execute( - $found, - array_merge($chain, [$type->getTypeScriptName()]) - ); - - $type->replaceSymbol($missingSymbol, $transformed); - - return $type; - } -} diff --git a/src/Actions/ResolveClassesInPhpFileAction.php b/src/Actions/ResolveClassesInPhpFileAction.php deleted file mode 100644 index 7dd7ef4..0000000 --- a/src/Actions/ResolveClassesInPhpFileAction.php +++ /dev/null @@ -1,48 +0,0 @@ -parser = (new ParserFactory())->create(ParserFactory::PREFER_PHP7); - } - - public function execute(SplFileInfo $file): array - { - $statements = $this->parser->parse($file->getContents()); - - $nodeFinder = new NodeFinder; - - $namespace = $nodeFinder->findFirst( - $statements, - fn ($node) => $node instanceof Namespace_ - ); - - $classes = $nodeFinder->find( - $statements, - fn ($node) => $node instanceof Class_ || $node instanceof Interface_ || $node instanceof Trait_ || $node instanceof Enum_ - ); - - return array_map(function (Class_|Interface_|Trait_|Enum_ $item) use ($namespace) { - $className = $namespace instanceof Namespace_ - ? "{$namespace->name}\\{$item->name}" - : $item->name; - - return preg_replace('/^\\\*/', '', (string) $className); - }, $classes); - } -} diff --git a/src/Actions/ResolveModuleImportsAction.php b/src/Actions/ResolveModuleImportsAction.php new file mode 100644 index 0000000..c7e8e88 --- /dev/null +++ b/src/Actions/ResolveModuleImportsAction.php @@ -0,0 +1,93 @@ +,string):string|null $alternativeNamesResolver + */ + public function __construct( + protected ResolveRelativePathAction $resolveRelativePathAction = new ResolveRelativePathAction(), + protected ?Closure $alternativeNamesResolver = null, + ) { + } + + public function execute( + Location $location, + TransformedCollection $transformedCollection, + ): ImportsCollection { + $collection = new ImportsCollection(); + + $usedNamesInModule = array_values( + array_map(fn (Transformed $transformed) => $transformed->getName(), $location->transformed) + ); + + foreach ($location->transformed as $transformedItem) { + foreach ($transformedItem->references as $referencedTransformedKey => $typeReferences) { + $referencedTransformed = $transformedCollection->get($referencedTransformedKey); + + if ($referencedTransformed->location === $location->segments) { + continue; + } + + if ($collection->hasReferenceImported($referencedTransformed->reference)) { + continue; + } + + $name = $referencedTransformed->getName(); + + $resolveImportedName = $this->resolveImportedName($usedNamesInModule, $name); + + $usedNamesInModule[] = $resolveImportedName; + + $importName = new ImportName( + $name, + $referencedTransformed->reference, + $name === $resolveImportedName ? null : $resolveImportedName, + ); + + $relativePath = $this->resolveRelativePathAction->execute( + $location->segments, + $referencedTransformed->location, + ); + + $collection->add($relativePath, $importName); + } + } + + return $collection; + } + + protected function resolveImportedName( + array $usedNamesInScope, + string $name, + ): string { + if ($this->alternativeNamesResolver) { + return ($this->alternativeNamesResolver)($usedNamesInScope, $name); + } + + if (! in_array($name, $usedNamesInScope)) { + return $name; + } + + if (! in_array("{$name}Import", $usedNamesInScope)) { + return "{$name}Import"; + } + + $counter = 2; + + while (in_array("{$name}Import{$counter}", $usedNamesInScope)) { + $counter++; + } + + return "{$name}Import{$counter}"; + } +} diff --git a/src/Actions/ResolveRelativePathAction.php b/src/Actions/ResolveRelativePathAction.php new file mode 100644 index 0000000..00aad8f --- /dev/null +++ b/src/Actions/ResolveRelativePathAction.php @@ -0,0 +1,41 @@ +finder = $finder; - - $this->config = $config; - - $this->collectors = $config->getCollectors(); - } - - public function execute(): TypesCollection - { - $collection = new TypesCollection(); - - $paths = $this->config->getAutoDiscoverTypesPaths(); - - if (empty($paths)) { - throw NoAutoDiscoverTypesPathsDefined::create(); - } - - foreach ($this->resolveIterator($paths) as $class) { - $transformedType = $this->resolveTransformedType($class); - - if ($transformedType === null) { - continue; - } - - $collection[] = $transformedType; - } - - return $collection; - } - - protected function resolveIterator(array $paths): Generator - { - $paths = array_map( - fn (string $path) => is_dir($path) ? $path : dirname($path), - $paths - ); - - foreach ($this->finder->in($paths) as $fileInfo) { - try { - $classes = (new ResolveClassesInPhpFileAction())->execute($fileInfo); - - foreach ($classes as $name) { - yield $name => new ReflectionClass($name); - } - } catch (Exception $exception) { - } - } - } - - protected function resolveTransformedType(ReflectionClass $class): ?TransformedType - { - foreach ($this->collectors as $collector) { - $transformedType = $collector->getTransformedType($class); - - if ($transformedType !== null) { - return $transformedType; - } - } - - return null; - } -} diff --git a/src/Actions/SplitTransformedPerLocationAction.php b/src/Actions/SplitTransformedPerLocationAction.php new file mode 100644 index 0000000..2680941 --- /dev/null +++ b/src/Actions/SplitTransformedPerLocationAction.php @@ -0,0 +1,38 @@ + + */ + public function execute(TransformedCollection $collection): array + { + $split = []; + + foreach ($collection as $transformed) { + $splitKey = count($transformed->location) > 0 + ? implode('.', $transformed->location) + : ''; + + if (! array_key_exists($splitKey, $split)) { + $split[$splitKey] = new Location($transformed->location, []); + } + + $split[$splitKey]->transformed[] = $transformed; + } + + ksort($split); + + foreach ($split as $splitConstruct) { + usort($splitConstruct->transformed, fn (Transformed $a, Transformed $b) => $a->getName() <=> $b->getName()); + } + + return array_values($split); + } +} diff --git a/src/Actions/TransformTypesAction.php b/src/Actions/TransformTypesAction.php new file mode 100644 index 0000000..6cb3a2d --- /dev/null +++ b/src/Actions/TransformTypesAction.php @@ -0,0 +1,61 @@ + $transformers + * @param array $discoveredClasses + * + * @return array + */ + public function execute( + array $transformers, + array $discoveredClasses, + ): array { + $types = []; + + foreach ($discoveredClasses as $discoveredClass) { + $transformed = $this->transformClassNode( + $transformers, + $discoveredClass + ); + + if ($transformed) { + $types[] = $transformed; + } + } + + return $types; + } + + public function transformClassNode( + array $transformers, + PhpClassNode $node + ): ?Transformed { + if (count($node->getAttributes(Hidden::class)) > 0) { + return null; + } + $transformationContext = TransformationContext::createFromPhpClass($node); + + foreach ($transformers as $transformer) { + $transformed = $transformer->transform( + $node, + $transformationContext, + ); + + if ($transformed instanceof Transformed) { + return $transformed; + } + } + + return null; + } +} diff --git a/src/Actions/TranspilePhpStanTypeToTypeScriptNodeAction.php b/src/Actions/TranspilePhpStanTypeToTypeScriptNodeAction.php new file mode 100644 index 0000000..8ef703d --- /dev/null +++ b/src/Actions/TranspilePhpStanTypeToTypeScriptNodeAction.php @@ -0,0 +1,244 @@ + $this->identifierNode($type, $phpClassNode), + ArrayTypeNode::class => $this->arrayTypeNode($type, $phpClassNode), + GenericTypeNode::class => $this->genericNode($type, $phpClassNode), + ArrayShapeNode::class, ObjectShapeNode::class => $this->arrayShapeNode($type, $phpClassNode), + NullableTypeNode::class => $this->nullableNode($type, $phpClassNode), + UnionTypeNode::class => $this->unionNode($type, $phpClassNode), + IntersectionTypeNode::class => $this->intersectionNode($type, $phpClassNode), + default => new TypeScriptUnknown(), + }; + } + + protected function identifierNode( + IdentifierTypeNode $node, + ?PhpClassNode $phpClassNode + ): TypeScriptNode { + if ($node->name === 'mixed') { + return new TypeScriptAny(); + } + + if ($node->name === 'string' || $node->name === 'class-string') { + return new TypeScriptString(); + } + + if ($node->name === 'float' || $node->name === 'double' || $node->name === 'int' || $node->name === 'integer') { + return new TypeScriptNumber(); + } + + if ($node->name === 'bool' || $node->name === 'boolean' || $node->name === 'true' || $node->name === 'false') { + return new TypeScriptBoolean(); + } + + if ($node->name === 'void') { + return new TypeScriptVoid(); + } + + if ($node->name === 'array') { + return new TypeScriptArray([]); + } + + if ($node->name === 'callable') { + return new TypeScriptFunction(); + } + + if ($node->name === 'null') { + return new TypeScriptNull(); + } + + if ($node->name === 'self' || $node->name === 'static') { + return new TypeReference(new ClassStringReference($phpClassNode->getName())); + } + + if ($node->name === 'object') { + return new TypeScriptObject([]); + } + + if ($node->name === 'array-key') { + return new TypeScriptUnion([ + new TypeScriptString(), + new TypeScriptNumber(), + ]); + } + + if (class_exists($node->name) || interface_exists($node->name)) { + return new TypeReference(new ClassStringReference($node->name)); + } + + if ($phpClassNode === null) { + return new TypeScriptUnknown(); + } + + $referenced = $this->findClassNameFqcnAction->execute( + $phpClassNode, + $node->name + ); + + if (class_exists($referenced) || interface_exists($referenced)) { + return new TypeReference(new ClassStringReference($referenced)); + } + + return new TypeScriptUnknown(); + } + + protected function arrayTypeNode( + ArrayTypeNode $node, + ?PhpClassNode $phpClassNode + ): TypeScriptNode { + return new TypeScriptArray( + [$this->execute($node->type, $phpClassNode)] + ); + } + + protected function arrayShapeNode( + ArrayShapeNode|ObjectShapeNode $node, + ?PhpClassNode $phpClassNode + ): TypeScriptObject { + return new TypeScriptObject(array_map( + function (ArrayShapeItemNode|ObjectShapeItemNode $item) use ($phpClassNode) { + return new TypeScriptProperty( + (string) $item->keyName, + $this->execute($item->valueType, $phpClassNode), + isOptional: $item->optional + ); + }, + $node->items + )); + } + + protected function nullableNode( + NullableTypeNode $node, + ?PhpClassNode $phpClassNode + ): TypeScriptNode { + $type = $this->execute($node->type, $phpClassNode); + + if (! $type instanceof TypeScriptUnion) { + return new TypeScriptUnion([$type, new TypeScriptNull()]); + } + + if ($type->contains(fn () => new TypeScriptNull())) { + $type->types[] = new TypeScriptNull(); + } + + return $type; + } + + protected function unionNode( + UnionTypeNode $node, + ?PhpClassNode $phpClassNode + ): TypeScriptUnion { + return new TypeScriptUnion(array_map( + fn (TypeNode $type) => $this->execute($type, $phpClassNode), + $node->types + )); + } + + protected function intersectionNode( + IntersectionTypeNode $node, + ?PhpClassNode $phpClassNode + ): TypeScriptIntersection { + return new TypeScriptIntersection(array_map( + fn (TypeNode $type) => $this->execute($type, $phpClassNode), + $node->types + )); + } + + protected function genericNode( + GenericTypeNode $node, + ?PhpClassNode $phpClassNode + ): TypeScriptNode { + if ($node->type->name === 'array' || $node->type->name === 'Array') { + return $this->genericArrayNode($node, $phpClassNode); + } + + $type = $this->execute($node->type, $phpClassNode); + + if ($type instanceof TypeScriptString) { + return $type; // class-string case + } + + return new TypeScriptGeneric( + $type, + array_map( + fn (TypeNode $type) => $this->execute($type, $phpClassNode), + $node->genericTypes + ) + ); + } + + private function genericArrayNode(GenericTypeNode $node, ?PhpClassNode $phpClassNode): TypeScriptGeneric|TypeScriptArray + { + $genericTypes = count($node->genericTypes); + + if ($genericTypes === 0) { + return new TypeScriptArray([]); + } + + if ($genericTypes === 1) { + return new TypeScriptArray([$this->execute($node->genericTypes[0], $phpClassNode)]); + } + + if ($genericTypes > 2) { + throw new Exception('Invalid number of generic types for array'); + } + + $key = $this->execute($node->genericTypes[0], $phpClassNode); + $value = $this->execute($node->genericTypes[1], $phpClassNode); + + if ($key instanceof TypeScriptNumber) { + return new TypeScriptArray([$value]); + } + + return new TypeScriptGeneric( + new TypeScriptIdentifier('Record'), + [$key, $value,] + ); + } +} diff --git a/src/Actions/TranspilePhpTypeNodeToTypeScriptNodeAction.php b/src/Actions/TranspilePhpTypeNodeToTypeScriptNodeAction.php new file mode 100644 index 0000000..139be81 --- /dev/null +++ b/src/Actions/TranspilePhpTypeNodeToTypeScriptNodeAction.php @@ -0,0 +1,132 @@ +resolveType($phpTypeNode, $phpClassNode); + + if ( + ! $phpTypeNode->allowsNull() + || $type instanceof TypeScriptAny + || $type instanceof TypeScriptNull) { + return $type; + } + + if ($type instanceof TypeScriptUnion && $type->contains(fn (TypeScriptNode $node) => $node instanceof TypeScriptNull)) { + return $type; + } + + if ($type instanceof TypeScriptUnion) { + $type->types[] = new TypeScriptNull(); + + return $type; + } + + return new TypeScriptUnion([$type, new TypeScriptNull()]); + } + + protected function resolveType( + PhpTypeNode $phpTypeNode, + PhpClassNode $phpClassNode, + ): TypeScriptNode { + return match ($phpTypeNode::class) { + PhpNamedTypeNode::class => $this->namedType($phpTypeNode, $phpClassNode), + PhpUnionTypeNode::class => $this->unionType($phpTypeNode, $phpClassNode), + PhpIntersectionTypeNode::class => $this->intersectionType($phpTypeNode, $phpClassNode), + default => new TypeScriptUndefined(), + }; + } + + protected function namedType( + PhpNamedTypeNode $type, + PhpClassNode $phpClassNode, + ): TypeScriptNode { + if ($type->getName() === 'string') { + return new TypeScriptString(); + } + + if ($type->getName() === 'float' || $type->getName() === 'int') { + return new TypeScriptNumber(); + } + + if ($type->getName() === 'bool' || $type->getName() === 'true' || $type->getName() === 'false') { + return new TypeScriptBoolean(); + } + + if ($type->getName() === 'array') { + return new TypeScriptArray([]); + } + + if ($type->getName() === 'null') { + return new TypeScriptNull(); + } + + if ($type->getName() === 'mixed') { + return new TypeScriptAny(); + } + + if ($type->getName() === 'self' || $type->getName() === 'static') { + return new TypeReference(new ClassStringReference($phpClassNode->getName())); + } + + if ($type->getName() === 'object') { + return new TypeScriptObject([]); + } + + if ($type->getName() === 'void') { + return new TypeScriptVoid(); + } + + if (class_exists($type->getName()) || interface_exists($type->getName())) { + return new TypeReference(new ClassStringReference($type->getName())); + } + + return new TypeScriptUnknown(); + } + + protected function unionType( + PhpUnionTypeNode $type, + PhpClassNode $phpClassNode, + ): TypeScriptNode { + return new TypeScriptUnion(array_map( + fn (PhpTypeNode $type) => $this->resolveType($type, $phpClassNode), + $type->getTypes() + )); + } + + protected function intersectionType( + PhpIntersectionTypeNode $type, + PhpClassNode $classNode, + ): TypeScriptNode { + return new TypeScriptIntersection(array_map( + fn (PhpTypeNode $type) => $this->resolveType($type, $classNode), + $type->getTypes() + )); + } +} diff --git a/src/Actions/TranspileTypeToTypeScriptAction.php b/src/Actions/TranspileTypeToTypeScriptAction.php deleted file mode 100644 index 87b2ebd..0000000 --- a/src/Actions/TranspileTypeToTypeScriptAction.php +++ /dev/null @@ -1,140 +0,0 @@ -missingSymbolsCollection = $missingSymbolsCollection; - $this->currentClass = $currentClass; - } - - public function execute(Type $type): string - { - return match (true) { - $type instanceof Compound => $this->resolveCompoundType($type), - $type instanceof AbstractList => $this->resolveListType($type), - $type instanceof Nullable => $this->resolveNullableType($type), - $type instanceof Object_ => $this->resolveObjectType($type), - $type instanceof StructType => $this->resolveStructType($type), - $type instanceof RecordType => $this->resolveRecordType($type), - $type instanceof TypeScriptType => (string) $type, - $type instanceof Boolean => 'boolean', - $type instanceof Float_, $type instanceof Integer => 'number', - $type instanceof String_, $type instanceof ClassString => 'string', - $type instanceof Null_ => 'null', - $type instanceof Self_, $type instanceof Static_, $type instanceof This => $this->resolveSelfReferenceType(), - $type instanceof Scalar => 'string|number|boolean', - $type instanceof Mixed_ => 'any', - $type instanceof Void_ => 'void', - default => throw new Exception("Could not transform type: {$type}") - }; - } - - private function resolveCompoundType(Compound $compound): string - { - $transformed = array_map( - fn (Type $type) => $this->execute($type), - iterator_to_array($compound->getIterator()) - ); - - return join(' | ', array_unique($transformed)); - } - - private function resolveListType(AbstractList $list): string - { - if ($this->isTypeScriptArray($list->getKeyType())) { - return "Array<{$this->execute($list->getValueType())}>"; - } - - return "{ [key: {$this->execute($list->getKeyType())}]: {$this->execute($list->getValueType())} }"; - } - - private function resolveNullableType(Nullable $nullable): string - { - return "{$this->execute($nullable->getActualType())} | null"; - } - - private function resolveObjectType(Object_ $object): string - { - if ($object->getFqsen() === null) { - return 'object'; - } - - return $this->missingSymbolsCollection->add( - (string) $object->getFqsen() - ); - } - - private function resolveStructType(StructType $type): string - { - $transformed = "{"; - - foreach ($type->getTypes() as $name => $type) { - $transformed .= "{$name}:{$this->execute($type)};"; - } - - return "{$transformed}}"; - } - - private function resolveRecordType(RecordType $type): string - { - return "Record<{$this->execute($type->getKeyType())}, {$this->execute($type->getValueType())}>"; - } - - private function resolveSelfReferenceType(): string - { - if ($this->currentClass === null) { - return 'any'; - } - - return $this->missingSymbolsCollection->add($this->currentClass); - } - - private function isTypeScriptArray(Type $keyType): bool - { - if (! $keyType instanceof Compound) { - return false; - } - - if ($keyType->getIterator()->count() !== 2) { - return false; - } - - if (! $keyType->contains(new String_()) || ! $keyType->contains(new Integer())) { - return false; - } - - return true; - } -} diff --git a/src/Actions/WriteFilesAction.php b/src/Actions/WriteFilesAction.php new file mode 100644 index 0000000..4bc6293 --- /dev/null +++ b/src/Actions/WriteFilesAction.php @@ -0,0 +1,34 @@ + $writeableFiles */ + public function execute( + array $writeableFiles + ): void { + foreach ($writeableFiles as $writeableFile) { + $this->writeFile($writeableFile); + } + } + + protected function writeFile(WriteableFile $file): void + { + $directory = dirname($file->path); + + if (is_dir($directory) === false) { + mkdir($directory, recursive: true); + } + + file_put_contents($file->path, $file->contents); + } +} diff --git a/src/Attributes/Hidden.php b/src/Attributes/Hidden.php index 8e6dbda..ec96e50 100644 --- a/src/Attributes/Hidden.php +++ b/src/Attributes/Hidden.php @@ -4,7 +4,7 @@ use Attribute; -#[Attribute(Attribute::TARGET_PROPERTY)] +#[Attribute] class Hidden { } diff --git a/src/Attributes/InlineTypeScriptType.php b/src/Attributes/InlineTypeScriptType.php deleted file mode 100644 index ab1811b..0000000 --- a/src/Attributes/InlineTypeScriptType.php +++ /dev/null @@ -1,10 +0,0 @@ -typeScript = $typeScript; } - public function getType(): Type + public function getType(PhpClassNode $class): TypeScriptNode { if (is_string($this->typeScript)) { - return new TypeScriptType($this->typeScript); + return new TypeScriptRaw($this->typeScript); } - $types = array_map( - fn (string $type) => new TypeScriptType($type), - $this->typeScript - ); + $properties = collect($this->typeScript) + ->map(fn (string $type, string $name) => new TypeScriptProperty( + $name, + new TypeScriptRaw($type) + )) + ->all(); - return new StructType($types); + return new TypeScriptObject($properties); } } diff --git a/src/Attributes/RecordTypeScriptType.php b/src/Attributes/RecordTypeScriptType.php deleted file mode 100644 index 311b830..0000000 --- a/src/Attributes/RecordTypeScriptType.php +++ /dev/null @@ -1,27 +0,0 @@ -keyType = $keyType; - $this->valueType = $valueType; - $this->array = $array; - } - - public function getType(): Type - { - return new RecordType($this->keyType, $this->valueType, $this->array); - } -} diff --git a/src/Attributes/TypeScript.php b/src/Attributes/TypeScript.php index b6365d1..fd6b85a 100644 --- a/src/Attributes/TypeScript.php +++ b/src/Attributes/TypeScript.php @@ -7,10 +7,12 @@ #[Attribute] class TypeScript { - public ?string $name; - - public function __construct(?string $name = null) - { - $this->name = $name; + /** + * @param array|null $location + */ + public function __construct( + public ?string $name = null, + public ?array $location = null, + ) { } } diff --git a/src/Attributes/TypeScriptTransformableAttribute.php b/src/Attributes/TypeScriptTransformableAttribute.php deleted file mode 100644 index fd09e3d..0000000 --- a/src/Attributes/TypeScriptTransformableAttribute.php +++ /dev/null @@ -1,10 +0,0 @@ -transformer = $transformer; - } -} diff --git a/src/Attributes/TypeScriptType.php b/src/Attributes/TypeScriptType.php index 75b2782..e417a92 100644 --- a/src/Attributes/TypeScriptType.php +++ b/src/Attributes/TypeScriptType.php @@ -3,32 +3,39 @@ namespace Spatie\TypeScriptTransformer\Attributes; use Attribute; -use phpDocumentor\Reflection\Type; -use phpDocumentor\Reflection\TypeResolver; -use Spatie\TypeScriptTransformer\Exceptions\UnableToTransformUsingAttribute; -use Spatie\TypeScriptTransformer\Types\StructType; +use Spatie\TypeScriptTransformer\Actions\TranspilePhpStanTypeToTypeScriptNodeAction; +use Spatie\TypeScriptTransformer\PhpNodes\PhpClassNode; +use Spatie\TypeScriptTransformer\TypeResolvers\DocTypeResolver; +use Spatie\TypeScriptTransformer\TypeScriptNodes\TypeScriptNode; +use Spatie\TypeScriptTransformer\TypeScriptNodes\TypeScriptObject; +use Spatie\TypeScriptTransformer\TypeScriptNodes\TypeScriptProperty; #[Attribute] -class TypeScriptType implements TypeScriptTransformableAttribute +class TypeScriptType implements TypeScriptTypeAttributeContract { - private array | string $type; + private array|string $type; - public function __construct(string | array $type) + public function __construct(string|array $type) { $this->type = $type; } - public function getType(): Type + public function getType(PhpClassNode $class): TypeScriptNode { + $docResolver = new DocTypeResolver(); + $transpiler = new TranspilePhpStanTypeToTypeScriptNodeAction(); + if (is_string($this->type)) { - return (new TypeResolver())->resolve($this->type); + return $transpiler->execute($docResolver->type($this->type), $class); } - /** @psalm-suppress RedundantCondition */ - if (is_array($this->type)) { - return StructType::fromArray($this->type); - } + $properties = collect($this->type) + ->map(fn (string $type, string $name) => new TypeScriptProperty( + $name, + $transpiler->execute($docResolver->type($type), $class) + )) + ->all(); - throw UnableToTransformUsingAttribute::create($this->type); + return new TypeScriptObject($properties); } } diff --git a/src/Attributes/TypeScriptTypeAttributeContract.php b/src/Attributes/TypeScriptTypeAttributeContract.php new file mode 100644 index 0000000..4b8e109 --- /dev/null +++ b/src/Attributes/TypeScriptTypeAttributeContract.php @@ -0,0 +1,11 @@ +visitor = Visitor::create()->before(function (TypeScriptGeneric $generic) { + $isCollection = $generic->type instanceof TypeReference + && $generic->type->reference instanceof ClassStringReference + && in_array($generic->type->reference->classString, $this->arrayLikeClassesToReplace); + + $isArrayToReplace = $this->replaceArrays + && $generic->type instanceof TypeScriptIdentifier + && $generic->type->name === 'Array' + && count($generic->genericTypes) === 2; // One type is totally valid + + if (! $isCollection && ! $isArrayToReplace) { + return VisitorOperation::keep(); + } + + $genericTypesCount = count($generic->genericTypes); + + if ($genericTypesCount > 2 || $genericTypesCount === 0) { + // Someone messed with the type, let's skip it + return VisitorOperation::keep(); + } + + if ($genericTypesCount === 1) { + return VisitorOperation::replace(new TypeScriptArray([$generic->genericTypes[0]])); + } + + $isRecord = $generic->genericTypes[0] instanceof TypeScriptUnion || $generic->genericTypes[0] instanceof TypeScriptString; + + if ($isRecord) { + return VisitorOperation::replace(new TypeScriptGeneric( + new TypeScriptIdentifier('Record'), + [ + $generic->genericTypes[0], + $generic->genericTypes[1], + ] + )); + } + + return VisitorOperation::replace(new TypeScriptArray([$generic->genericTypes[1]])); + }, [TypeScriptGeneric::class]); + } + + public function replaceArrayLikeClass(string ...$class): self + { + array_push($this->arrayLikeClassesToReplace, ...$class); + + return $this; + } + + public function execute( + PhpPropertyNode $phpPropertyNode, + ?TypeNode $annotation, + TypeScriptProperty $property + ): ?TypeScriptProperty { + $property->type = $this->visitor->execute($property->type); + + return $property; + } +} diff --git a/src/ClassReader.php b/src/ClassReader.php deleted file mode 100644 index b96bf7d..0000000 --- a/src/ClassReader.php +++ /dev/null @@ -1,64 +0,0 @@ - $this->resolveTransformable($class), - 'name' => $this->resolveName($class), - 'transformer' => $this->resolveTransformer($class), - 'inline' => $this->resolveInline($class), - ]; - } - - protected function resolveTransformable(ReflectionClass $class): bool - { - return str_contains($class->getDocComment(), '@typescript'); - } - - protected function resolveName(ReflectionClass $class): string - { - $annotations = []; - - preg_match( - '/@typescript\s*([\w\/\.]*)\s*/', - $class->getDocComment(), - $annotations - ); - - $name = $annotations[1] ?? null; - - if (count($annotations) !== 2 || empty($name)) { - return $class->getShortName(); - } - - return $name; - } - - protected function resolveTransformer(ReflectionClass $class): ?string - { - $annotations = []; - - preg_match( - '/@typescript-transformer\s+([\w\\\]*)/', - $class->getDocComment(), - $annotations - ); - - if (count($annotations) !== 2 || empty($annotations[1])) { - return null; - } - - return $annotations[1]; - } - - private function resolveInline(ReflectionClass $class): bool - { - return str_contains($class->getDocComment(), '@typescript-inline'); - } -} diff --git a/src/Collections/ImportsCollection.php b/src/Collections/ImportsCollection.php new file mode 100644 index 0000000..60d7486 --- /dev/null +++ b/src/Collections/ImportsCollection.php @@ -0,0 +1,73 @@ + $imports + */ + public function __construct( + protected array $imports = [], + ) { + } + + public function add(string $relativePath, ImportName $name): void + { + if (! array_key_exists($relativePath, $this->imports)) { + $this->imports[$relativePath] = new ImportLocation($relativePath); + } + + $this->imports[$relativePath]->addName($name); + } + + public function getAliasOrNameForReference(Reference $reference): ?string + { + foreach ($this->imports as $import) { + if ($aliasOrName = $import->getAliasOrNameForReference($reference)) { + return $aliasOrName; + } + } + + return null; + } + + public function hasReferenceImported(Reference $reference): bool + { + return $this->getAliasOrNameForReference($reference) !== null; + } + + public function isEmpty(): bool + { + return empty($this->imports); + } + + public function getIterator(): Traversable + { + return new \ArrayIterator($this->imports); + } + + /** + * @return array + */ + public function getTypeScriptNodes(): array + { + return array_values(array_map( + fn (ImportLocation $import) => $import->toTypeScriptNode(), + $this->imports, + )); + } + + /** @return array */ + public function toArray(): array + { + return array_values($this->imports); + } +} diff --git a/src/Collections/TransformedCollection.php b/src/Collections/TransformedCollection.php new file mode 100644 index 0000000..8a3231c --- /dev/null +++ b/src/Collections/TransformedCollection.php @@ -0,0 +1,129 @@ + + */ +class TransformedCollection implements IteratorAggregate +{ + /** @var array */ + protected array $items = []; + + /** @var array */ + protected array $fileMapping = []; + + public function __construct( + array $items = [], + ) { + $this->add(...$items); + } + + public function add(Transformed ...$transformed): self + { + foreach ($transformed as $item) { + $this->items[$item->reference->getKey()] = $item; + + if ($item->reference instanceof FilesystemReference) { + $this->fileMapping[$this->cleanupFilePath($item->reference->getFilesystemOriginPath())] = $item; + } + } + + return $this; + } + + public function has(Reference|string $reference): bool + { + return array_key_exists(is_string($reference) ? $reference : $reference->getKey(), $this->items); + } + + public function get(Reference|string $reference): ?Transformed + { + return $this->items[is_string($reference) ? $reference : $reference->getKey()] ?? null; + } + + public function remove(Reference|string $reference): void + { + $transformed = $this->get($reference); + + if ($transformed === null) { + return; + } + + foreach (array_unique($transformed->referencedBy) as $referencedBy) { + $referencedBy = $this->get($referencedBy); + + $referencedBy->markReferenceMissing($transformed); + $referencedBy->markAsChanged(); + } + + unset($this->items[$transformed->reference->getKey()]); + + if ($transformed->reference instanceof FilesystemReference) { + $path = $this->cleanupFilePath($transformed->reference->getFilesystemOriginPath()); + + unset($this->fileMapping[$path]); + } + } + + public function getIterator(): Traversable + { + return new ArrayIterator($this->items); + } + + public function all(): array + { + return $this->items; + } + + public function onlyChanged(): Generator + { + foreach ($this->items as $item) { + if ($item->changed) { + yield $item; + } + } + } + + public function findTransformedByFile(string $path): ?Transformed + { + $path = $this->cleanupFilePath($path); + + return $this->fileMapping[$path] ?? null; + } + + public function findTransformedByDirectory(string $path): Generator + { + $path = $this->cleanupFilePath($path); + + foreach ($this->fileMapping as $transformedPath => $transformed) { + if (str_starts_with($transformedPath, $path)) { + yield $transformed; + } + } + } + + public function hasChanges(): bool + { + foreach ($this->items as $item) { + if ($item->changed) { + return true; + } + } + + return false; + } + + protected function cleanupFilePath(string $path): string + { + return realpath($path); + } +} diff --git a/src/Collectors/Collector.php b/src/Collectors/Collector.php deleted file mode 100644 index bde7894..0000000 --- a/src/Collectors/Collector.php +++ /dev/null @@ -1,19 +0,0 @@ -config = $config; - } - - abstract public function getTransformedType(ReflectionClass $class): ?TransformedType; -} diff --git a/src/Collectors/DefaultCollector.php b/src/Collectors/DefaultCollector.php deleted file mode 100644 index 4626151..0000000 --- a/src/Collectors/DefaultCollector.php +++ /dev/null @@ -1,99 +0,0 @@ -isTransformable()) { - return null; - } - - $transformedType = $reflector->getType() - ? $this->resolveAlreadyTransformedType($reflector) - : $this->resolveTypeViaTransformer($reflector); - - if ($reflector->isInline()) { - $transformedType->name = null; - $transformedType->isInline = true; - } - - return $transformedType; - } - - protected function resolveAlreadyTransformedType(ClassTypeReflector $reflector): TransformedType - { - $missingSymbols = new MissingSymbolsCollection(); - $name = $reflector->getName(); - - $transpiler = new TranspileTypeToTypeScriptAction( - $missingSymbols, - $name - ); - - return TransformedType::create( - $reflector->getReflectionClass(), - $reflector->getName(), - $transpiler->execute($reflector->getType()), - $missingSymbols - ); - } - - protected function resolveTypeViaTransformer(ClassTypeReflector $reflector): ?TransformedType - { - $transformerClass = $reflector->getTransformerClass(); - - if ($transformerClass !== null) { - return $this->resolveTypeViaPredefinedTransformer($reflector); - } - - foreach ($this->config->getTransformers() as $transformer) { - $transformed = $transformer->transform( - $reflector->getReflectionClass(), - $reflector->getName() - ); - - if ($transformed !== null) { - return $transformed; - } - } - - throw TransformerNotFound::create($reflector->getReflectionClass()); - } - - protected function resolveTypeViaPredefinedTransformer(ClassTypeReflector $reflector): ?TransformedType - { - if (! class_exists($reflector->getTransformerClass())) { - throw InvalidTransformerGiven::classDoesNotExist( - $reflector->getReflectionClass(), - $reflector->getTransformerClass() - ); - } - - if (! is_subclass_of($reflector->getTransformerClass(), Transformer::class)) { - throw InvalidTransformerGiven::classIsNotATransformer( - $reflector->getReflectionClass(), - $reflector->getTransformerClass() - ); - } - - $transformer = $this->config->buildTransformer($reflector->getTransformerClass()); - - return $transformer->transform( - $reflector->getReflectionClass(), - $reflector->getName() - ); - } -} diff --git a/src/Collectors/EnumCollector.php b/src/Collectors/EnumCollector.php deleted file mode 100644 index 7ccca5c..0000000 --- a/src/Collectors/EnumCollector.php +++ /dev/null @@ -1,38 +0,0 @@ -config->getTransformers()); - - if (! \in_array(EnumTransformer::class, $transformers, true)) { - return null; - } - - $reflector = ClassTypeReflector::create($class); - - if (! $reflector->getReflectionClass()->implementsInterface(BackedEnum::class)) { - return null; - } - - $transformedType = $reflector->getType() - ? $this->resolveAlreadyTransformedType($reflector) - : $this->resolveTypeViaTransformer($reflector); - - if ($reflector->isInline()) { - $transformedType->name = null; - $transformedType->isInline = true; - } - - return $transformedType; - } -} diff --git a/src/Data/ImportLocation.php b/src/Data/ImportLocation.php new file mode 100644 index 0000000..31a945e --- /dev/null +++ b/src/Data/ImportLocation.php @@ -0,0 +1,45 @@ + $importNames + */ + public function __construct( + protected string $relativePath, + protected array $importNames = [], + ) { + } + + public function addName(ImportName $name): void + { + $this->importNames[] = $name; + } + + public function getAliasOrNameForReference(Reference $reference): ?string + { + foreach ($this->importNames as $importName) { + if ($importName->reference->getKey() === $reference->getKey()) { + return $importName->alias ?? $importName->name; + } + } + + return null; + } + + public function toTypeScriptNode(): ?TypeScriptImport + { + if ($this->relativePath === null) { + // current path + return null; + } + + return new TypeScriptImport($this->relativePath, $this->importNames); + } +} diff --git a/src/Events/Watch/DirectoryDeletedWatchEvent.php b/src/Events/Watch/DirectoryDeletedWatchEvent.php new file mode 100644 index 0000000..58bad04 --- /dev/null +++ b/src/Events/Watch/DirectoryDeletedWatchEvent.php @@ -0,0 +1,7 @@ + ', $chain)); - } -} diff --git a/src/Exceptions/InvalidDefaultTypeReplacer.php b/src/Exceptions/InvalidDefaultTypeReplacer.php deleted file mode 100644 index 1423b0d..0000000 --- a/src/Exceptions/InvalidDefaultTypeReplacer.php +++ /dev/null @@ -1,13 +0,0 @@ -getName()}) does not exist!"); - } - - public static function classIsNotATransformer(ReflectionClass $reflectionClass, string $transformerClass) - { - return new self("The transformer ({$transformerClass}) defined in ({$reflectionClass->getName()}) does not implement the Transformer interface!"); - } -} diff --git a/src/Exceptions/NoAutoDiscoverTypesPathsDefined.php b/src/Exceptions/NoAutoDiscoverTypesPathsDefined.php deleted file mode 100644 index 32eee3e..0000000 --- a/src/Exceptions/NoAutoDiscoverTypesPathsDefined.php +++ /dev/null @@ -1,13 +0,0 @@ - $kind, 'value' => $value] = $existing; - - return new self("Tried adding namespace: {$namespace} but a {$kind} already exists: $value"); - } - - public static function whenAddingType(string $type, array $existing): self - { - ['kind' => $kind, 'value' => $value] = $existing; - - return new self("Tried adding type: {$type} but a {$kind} already exists: $value"); - } -} diff --git a/src/Exceptions/TransformerNotFound.php b/src/Exceptions/TransformerNotFound.php deleted file mode 100644 index f4a611b..0000000 --- a/src/Exceptions/TransformerNotFound.php +++ /dev/null @@ -1,14 +0,0 @@ -getName()}!"); - } -} diff --git a/src/Exceptions/UnableToTransformUsingAttribute.php b/src/Exceptions/UnableToTransformUsingAttribute.php deleted file mode 100644 index 04fbcc2..0000000 --- a/src/Exceptions/UnableToTransformUsingAttribute.php +++ /dev/null @@ -1,15 +0,0 @@ -, WatchEventHandler> */ + protected array $handlers = []; + + public function __construct( + protected TypeScriptTransformer $typeScriptTransformer, + protected TransformedCollection $transformedCollection, + ) { + $this->initializeHandlers(); + } + + public function run(): void + { + $watcher = Watch::paths($this->typeScriptTransformer->config->directoriesToWatch) + ->onFileCreated(function (string $path) { + if (! str_ends_with($path, '.php')) { + return; + } + + $this->eventsBuffer[] = new FileCreatedWatchEvent($path); + }) + ->onfileUpdated(function (string $path) { + if (! str_ends_with($path, '.php')) { + return; + } + + $this->eventsBuffer[] = new FileUpdatedWatchEvent($path); + }) + ->onFileDeleted(function (string $path) { + if (! str_ends_with($path, '.php')) { + return; + } + + $this->eventsBuffer[] = new FileDeletedWatchEvent($path); + }) + ->onDirectoryDeleted(function (string $path) { + $this->eventsBuffer[] = new DirectoryDeletedWatchEvent($path); + }) + ->shouldContinue(function () { + // TODO: we probably want a better implementation than this but it works + if (count($this->eventsBuffer) > 0 && $this->processing === false) { + $this->processing = true; + $this->processBuffer(); + $this->processing = false; + } + + return true; + }); + + try { + $this->typeScriptTransformer->log->info('Starting watcher'); + + $watcher->start(); + } catch (CouldNotStartWatcher $e) { + throw new Exception( + 'Could not start watcher. Make sure you have required chokidar. (https://github.com/spatie/file-system-watcher?tab=readme-ov-file#installation)' + ); + } + } + + protected function initializeHandlers(): void + { + // TODO: handle directory deleted + + $this->handlers[FileCreatedWatchEvent::class] = new FileUpdatedOrCreatedWatchEventHandler( + $this->typeScriptTransformer, + $this->transformedCollection, + ); + + $this->handlers[FileUpdatedWatchEvent::class] = new FileUpdatedOrCreatedWatchEventHandler( + $this->typeScriptTransformer, + $this->transformedCollection, + ); + + $this->handlers[FileDeletedWatchEvent::class] = new FileDeletedWatchEventHandler( + $this->typeScriptTransformer, + $this->transformedCollection, + ); + } + + protected function processBuffer(): void + { + $this->typeScriptTransformer->log->info('Processing events'); + + [$events, $this->eventsBuffer] = [$this->eventsBuffer, []]; + + foreach ($events as $event) { + $this->handlers[$event::class]->handle($event); + } + + $this->typeScriptTransformer->executeProvidedClosuresAction->execute( + $this->transformedCollection->onlyChanged() + ); + + $this->typeScriptTransformer->connectReferencesAction->execute( + $this->transformedCollection + ); + + $this->tryToConnectMissingReferencesWithNewTransformed(); + + $this->typeScriptTransformer->executeConnectedClosuresAction->execute( + $this->transformedCollection->onlyChanged() + ); + + $this->typeScriptTransformer->outputTransformed( + $this->transformedCollection, + ); + + $this->typeScriptTransformer->log->info('Processed events'); + } + + protected function tryToConnectMissingReferencesWithNewTransformed(): void + { + foreach ($this->transformedCollection as $currentTransformed) { + foreach ($currentTransformed->missingReferences as $missingReference => $typeReferences) { + $foundTransformed = $this->transformedCollection->get($missingReference); + + if ($foundTransformed === null) { + continue; + } + + $currentTransformed->markMissingReferenceFound($foundTransformed); + + break; + } + } + } +} diff --git a/src/Formatters/EslintFormatter.php b/src/Formatters/EslintFormatter.php index ba16062..db74eed 100644 --- a/src/Formatters/EslintFormatter.php +++ b/src/Formatters/EslintFormatter.php @@ -7,9 +7,9 @@ class EslintFormatter implements Formatter { - public function format(string $file): void + public function format(array $files): void { - $process = new Process(['npx', '--yes', 'eslint', '--fix', '--no-ignore', $file]); + $process = new Process(['npx', '--yes', 'eslint', '--fix', '--no-ignore', ...$files]); $process->run(); if (! $process->isSuccessful()) { diff --git a/src/Formatters/Formatter.php b/src/Formatters/Formatter.php index 34cc6a1..bf93211 100644 --- a/src/Formatters/Formatter.php +++ b/src/Formatters/Formatter.php @@ -4,5 +4,5 @@ interface Formatter { - public function format(string $file): void; + public function format(array $files): void; } diff --git a/src/Formatters/PrettierFormatter.php b/src/Formatters/PrettierFormatter.php index 34ba901..ada4119 100644 --- a/src/Formatters/PrettierFormatter.php +++ b/src/Formatters/PrettierFormatter.php @@ -7,9 +7,9 @@ class PrettierFormatter implements Formatter { - public function format(string $file): void + public function format(array $files): void { - $process = new Process(['npx', '--yes', 'prettier', '--write', $file]); + $process = new Process(['npx', '--yes', 'prettier', '--write', ...$files]); $process->run(); if (! $process->isSuccessful()) { diff --git a/src/Handlers/Watch/DirectoryDeletedWatchEventHandler.php b/src/Handlers/Watch/DirectoryDeletedWatchEventHandler.php new file mode 100644 index 0000000..0f7c952 --- /dev/null +++ b/src/Handlers/Watch/DirectoryDeletedWatchEventHandler.php @@ -0,0 +1,28 @@ +transformedCollection->findTransformedByDirectory($event->path); + + foreach ($transformedItems as $transformed) { + $this->transformedCollection->remove($transformed->reference); + } + } +} diff --git a/src/Handlers/Watch/FileDeletedWatchEventHandler.php b/src/Handlers/Watch/FileDeletedWatchEventHandler.php new file mode 100644 index 0000000..1fbd4c9 --- /dev/null +++ b/src/Handlers/Watch/FileDeletedWatchEventHandler.php @@ -0,0 +1,30 @@ +transformedCollection->findTransformedByFile($event->path); + + if ($transformed === null) { + return; + } + + $this->transformedCollection->remove($transformed->reference); + } +} diff --git a/src/Handlers/Watch/FileUpdatedOrCreatedWatchEventHandler.php b/src/Handlers/Watch/FileUpdatedOrCreatedWatchEventHandler.php new file mode 100644 index 0000000..50cacc5 --- /dev/null +++ b/src/Handlers/Watch/FileUpdatedOrCreatedWatchEventHandler.php @@ -0,0 +1,74 @@ +typeScriptTransformer->loadPhpClassNodeAction->execute($event->path); + + if ($classNode === null) { + $this->typeScriptTransformer->log->warning("Multiple class nodes found in {$event->path}"); + + return; + } + + $newlyTransformed = $this->typeScriptTransformer->transformTypesAction->transformClassNode( + $this->typeScriptTransformer->config->transformers, + $classNode + ); + } catch (Throwable $throwable) { + if (str_starts_with($throwable::class, 'Roave\BetterReflection')) { + return; + } + + throw $throwable; + } + + $originalTransformed = $this->transformedCollection->findTransformedByFile( + $event->path + ); + + if ($originalTransformed && $newlyTransformed === null) { + $this->transformedCollection->remove($originalTransformed->reference); + // TODO: when removing a ts transformed structure (e.g. remove the TypeScript Attributes) + // everything is correctly removed from the collection + // but since there are no changes, no new rewrite is triggered + // somehow we should be able to trigger rewrites based upon namespaces + } + + if ($newlyTransformed === null) { + $this->typeScriptTransformer->log->warning("Could not transform {$event->path}"); + + return; + } + + // TODO: at the moment we replace the node when we see an update + // it could be that no changes are actually made + // and such a case nothing should be updated + + if ($originalTransformed !== null) { + $this->transformedCollection->remove($originalTransformed->reference); + } + + $this->transformedCollection->add($newlyTransformed); + } +} diff --git a/src/Handlers/Watch/WatchEventHandler.php b/src/Handlers/Watch/WatchEventHandler.php new file mode 100644 index 0000000..b6f71a8 --- /dev/null +++ b/src/Handlers/Watch/WatchEventHandler.php @@ -0,0 +1,8 @@ + $filters + */ + public function execute( + ?string $defaultNamespace, + bool $includeRouteClosures, + array $filters = [], + ): RouteCollection { + /** @var array $controllers */ + $controllers = []; + /** @var array $closures */ + $closures = []; + + foreach (app(Router::class)->getRoutes()->getRoutes() as $route) { + foreach ($filters as $filter) { + if ($filter->shouldHide($route)) { + continue 2; + } + } + + $controllerClass = $route->getControllerClass(); + + if ($controllerClass === null && ! $includeRouteClosures) { + continue; + } + + if ($controllerClass === null) { + $name = "Closure({$route->uri})"; + + $closures[$name] = new RouteClosure( + $this->resolveRouteParameters($route), + $route->methods, + $this->resolveUrl($route), + $route->getName(), + ); + + continue; + } + + $controllerClass = Str::of($controllerClass)->trim('\\'); + + $controllerClass = $defaultNamespace + ? $this->replaceDefaultNamespace($controllerClass, $defaultNamespace) + : $controllerClass->prepend('.'); + + $controllerClass = (string) $controllerClass->replace('\\', '.'); + + if ($route->getActionMethod() === $route->getControllerClass()) { + $controllers[$controllerClass] = new RouteInvokableController( + $this->resolveRouteParameters($route), + $route->methods, + $this->resolveUrl($route), + $route->getName(), + ); + + continue; + } + + if (! array_key_exists($controllerClass, $controllers)) { + $controllers[$controllerClass] = new RouteController([]); + } + + $controllers[$controllerClass]->actions[$route->getActionMethod()] = new RouteControllerAction( + $this->resolveRouteParameters($route), + $route->methods, + $this->resolveUrl($route), + $route->getName(), + ); + } + + return new RouteCollection($controllers, $closures); + } + + protected function replaceDefaultNamespace( + Stringable $controllerClass, + string $defaultNamespace + ): Stringable { + $defaultNamespace = Str::of($defaultNamespace)->trim('\\'); + + if (! $controllerClass->contains($defaultNamespace)) { + return $controllerClass->prepend('.'); + } + + return $controllerClass->replace($defaultNamespace, '')->trim('\\'); + } + + protected function resolveRouteParameters( + Route $route + ): RouteParameterCollection { + preg_match_all('/\{(.*?)\}/', $route->getDomain().$route->uri, $matches); + + $parameters = array_map(fn (string $match) => new RouteParameter( + trim($match, '?'), + str_ends_with($match, '?') + ), $matches[1]); + + return new RouteParameterCollection($parameters); + } + + protected function resolveUrl(Route $route): string + { + return str_replace('?}', '}', $route->getDomain().$route->uri); + } +} diff --git a/src/Laravel/ClassPropertyProcessors/DataClassPropertyProcessor.php b/src/Laravel/ClassPropertyProcessors/DataClassPropertyProcessor.php new file mode 100644 index 0000000..66d7278 --- /dev/null +++ b/src/Laravel/ClassPropertyProcessors/DataClassPropertyProcessor.php @@ -0,0 +1,84 @@ +lazyTypes = array_merge($this->lazyTypes, $this->customLazyTypes); + } + + public function execute( + PhpPropertyNode $phpPropertyNode, + ?TypeNode $annotation, + TypeScriptProperty $property + ): ?TypeScriptProperty { + if (! empty($phpPropertyNode->getAttributes(Hidden::class)) && ! empty($phpPropertyNode->getAttributes(DataHidden::class))) { + return null; + } + + // TODO: somehow get mapping working here without dataconfig and dataproperty + // $phpAttributeNodes = $phpPropertyNode->getAttributes(MapOutputName::class); + // + // if ($phpAttributeNodes) { + // $property->name = new TypeScriptIdentifier($dataProperty->outputMappedName); + // } + + if (! $property->type instanceof TypeScriptUnion) { + return $property; + } + + for ($i = 0; $i < count($property->type->types); $i++) { + $subType = $property->type->types[$i]; + + if ($subType instanceof TypeReference && $this->shouldHideReference($subType)) { + $property->isOptional = true; + + unset($property->type->types[$i]); + } + } + + $property->type->types = array_values($property->type->types); + + if (count($property->type->types) === 1) { + $property->type = $property->type->types[0]; + } + + return $property; + } + + protected function shouldHideReference( + TypeReference $reference + ): bool { + if (! $reference->reference instanceof ClassStringReference) { + return false; + } + + return in_array($reference->reference->classString, $this->lazyTypes) + || $reference->reference->classString === Optional::class; + } +} diff --git a/src/Laravel/Commands/InstallTypeScriptTransformerCommand.php b/src/Laravel/Commands/InstallTypeScriptTransformerCommand.php new file mode 100644 index 0000000..a74fe2b --- /dev/null +++ b/src/Laravel/Commands/InstallTypeScriptTransformerCommand.php @@ -0,0 +1,69 @@ +comment('Publishing TypeScript Transformer Service Provider...'); + $this->callSilent('vendor:publish', ['--tag' => 'typescript-transformer-provider']); + + $namespace = Str::replaceLast('\\', '', $this->laravel->getNamespace()); + + $this->installServiceProvider($namespace); + $this->registerServiceProvider($namespace); + } + + protected function installServiceProvider(string $namespace): void + { + $serviceProviderPath = app_path('Providers/TypeScriptTransformerServiceProvider.php'); + + if (file_exists($serviceProviderPath)) { + $this->info('TypeScript Transformer Service Provider already installed.'); + + return; + } + + file_put_contents($serviceProviderPath, str_replace( + "namespace App\Providers;", + "namespace {$namespace}\Providers;", + file_get_contents($serviceProviderPath) + )); + + $this->info('TypeScript Transformer Service Provider installed.'); + } + + protected function registerServiceProvider(string $namespace): void + { + $configFile = version_compare($this->laravel->version(), '11.0.0', '>=') ? + base_path('bootstrap/providers.php') : + config_path('app.php'); + + $appConfig = file_get_contents($configFile); + + if (Str::contains($appConfig, $namespace.'\\Providers\\TypeScriptTransformerServiceProvider::class')) { + $this->info('TypeScript Transformer Service Provider already registered.'); + + return; + } + + file_put_contents($configFile, str_replace( + "{$namespace}\\Providers\AppServiceProvider::class,".PHP_EOL, + "{$namespace}\\Providers\AppServiceProvider::class,".PHP_EOL." {$namespace}\Providers\TypeScriptTransformerServiceProvider::class,".PHP_EOL, + $appConfig + )); + + $this->info('TypeScript Transformer Service Provider registered.'); + } +} diff --git a/src/Laravel/Commands/TransformTypeScriptCommand.php b/src/Laravel/Commands/TransformTypeScriptCommand.php new file mode 100644 index 0000000..755dcd8 --- /dev/null +++ b/src/Laravel/Commands/TransformTypeScriptCommand.php @@ -0,0 +1,33 @@ +has(TypeScriptTransformerConfig::class)) { + $this->error('Please, first publish the TypeScriptTransformerServiceProvider and configure it.'); + + return self::FAILURE; + } + + TypeScriptTransformer::create( + config: app(TypeScriptTransformerConfig::class), + console: new WrappedLaravelConsole($this) + )->execute(); + + $this->comment('All done'); + + return self::SUCCESS; + } +} diff --git a/src/Laravel/Commands/WatchTypeScriptCommand.php b/src/Laravel/Commands/WatchTypeScriptCommand.php new file mode 100644 index 0000000..26f4da3 --- /dev/null +++ b/src/Laravel/Commands/WatchTypeScriptCommand.php @@ -0,0 +1,34 @@ +has(TypeScriptTransformerConfig::class)) { + $this->error('Please, first publish the TypeScriptTransformerServiceProvider and configure it.'); + + return self::FAILURE; + } + + TypeScriptTransformer::create( + config: app(TypeScriptTransformerConfig::class), + console: new WrappedLaravelConsole($this), + watch: true + )->execute(); + + $this->comment('Watching for changes...'); + + return self::SUCCESS; + } +} diff --git a/src/Laravel/LaravelDataTypeScriptTransformerExtension.php b/src/Laravel/LaravelDataTypeScriptTransformerExtension.php new file mode 100644 index 0000000..9f27a4b --- /dev/null +++ b/src/Laravel/LaravelDataTypeScriptTransformerExtension.php @@ -0,0 +1,28 @@ +extension(new LaravelTypeScriptTransformerExtension()); + + $factory->prependTransformer(new DataClassTransformer( + customLazyTypes: $this->customLazyTypes, + customDataCollections: $this->customDataCollections, + )); + + $factory->typesProvider(LaravelDataTypesProvider::class); + } +} diff --git a/src/Laravel/LaravelDataTypesProvider.php b/src/Laravel/LaravelDataTypesProvider.php new file mode 100644 index 0000000..aacf0f3 --- /dev/null +++ b/src/Laravel/LaravelDataTypesProvider.php @@ -0,0 +1,60 @@ +add( + $this->paginatedCollection(), + $this->cursorPaginatedCollection(), + ); + } + + protected function paginatedCollection(): Transformed + { + return new Transformed( + new TypeScriptAlias( + new TypeScriptGeneric( + new TypeScriptIdentifier('PaginatedDataCollection'), + [new TypeScriptIdentifier('TKey'), new TypeScriptIdentifier('TValue')], + ), + new TypeReference(new ClassStringReference(LengthAwarePaginator::class)) + ), + new ClassStringReference(PaginatedDataCollection::class), + ['Spatie', 'LaravelData'], + true, + ); + } + + protected function cursorPaginatedCollection(): Transformed + { + return new Transformed( + new TypeScriptAlias( + new TypeScriptGeneric( + new TypeScriptIdentifier('CursorPaginatedDataCollection'), + [new TypeScriptIdentifier('TKey'), new TypeScriptIdentifier('TValue')], + ), + new TypeReference(new ClassStringReference(CursorPaginator::class)) + ), + new ClassStringReference(CursorPaginatedDataCollection::class), + ['Spatie', 'LaravelData'], + true, + ); + } +} diff --git a/src/Laravel/LaravelNamedRouteTypesProvider.php b/src/Laravel/LaravelNamedRouteTypesProvider.php new file mode 100644 index 0000000..723d4f8 --- /dev/null +++ b/src/Laravel/LaravelNamedRouteTypesProvider.php @@ -0,0 +1,203 @@ + $location + * @param array $filters + */ + public function __construct( + protected ResolveLaravelRoutControllerCollectionsAction $resolveLaravelRoutControllerCollectionsAction = new ResolveLaravelRoutControllerCollectionsAction(), + protected array $location = ['App'], + protected array $filters = [], + ) { + } + + public function provide(TypeScriptTransformerConfig $config, TransformedCollection $types): void + { + $routeCollection = $this->resolveLaravelRoutControllerCollectionsAction->execute( + defaultNamespace: null, + includeRouteClosures: true, + filters: $this->filters, + ); + + $transformedRoutes = new Transformed( + new TypeScriptAlias( + new TypeScriptIdentifier('NamedRouteList'), + $this->parseRouteCollection($routeCollection), + ), + $routesListReference = new CustomReference('laravel_named_routes', 'routes_list'), + $this->location, + true, + ); + + $jsonEncodedRoutes = $this->routeCollectionToJson($routeCollection); + $baseUrl = url('/'); + + $transformedRoute = new Transformed( + new TypeScriptFunctionDefinition( + new TypeScriptGeneric( + new TypeScriptIdentifier('route'), + [ + new TypeScriptGenericTypeVariable( + new TypeScriptIdentifier('TRoute'), + extends: TypeScriptOperator::keyof(new TypeReference($routesListReference)) + ), + ] + ), + [ + new TypeScriptParameter('route', new TypeScriptIdentifier('TRoute')), + new TypeScriptParameter( + 'parameters', + new TypeScriptIndexedAccess(new TypeReference($routesListReference), [ + new TypeScriptIdentifier('TRoute'), + new TypeScriptIdentifier('"parameters"'), + ]), + isOptional: true + ), + ], + new TypeScriptString(), + new TypeScriptRaw( + <<location, + true, + ); + + $types->add($transformedRoutes, $transformedRoute); + } + + protected function parseRouteCollection(RouteCollection $collection): TypeScriptNode + { + $mappingFunction = fn (RouteControllerAction|RouteInvokableController|RouteClosure $entity) => new TypeScriptProperty( + $entity->name, + new TypeScriptObject([ + new TypeScriptProperty( + 'parameters', + $this->parseRouteParameterCollection($entity->parameters), + ), + ]) + ); + + $properties = collect(array_merge($collection->controllers, $collection->closures)) + ->flatMap(function (RouteController|RouteInvokableController|RouteClosure $entity) use ($mappingFunction) { + $singleRoute = $entity instanceof RouteInvokableController || $entity instanceof RouteClosure; + + if ($singleRoute && $entity->name) { + return [$mappingFunction($entity)]; + } + + if ($entity instanceof RouteController) { + return collect($entity->actions) + ->filter(fn (RouteControllerAction $action) => $action->name) + ->values() + ->map($mappingFunction); + } + + return []; + }) + ->all(); + + return new TypeScriptObject($properties); + } + + protected function parseRouteParameterCollection(RouteParameterCollection $collection): TypeScriptNode + { + return new TypeScriptObject(array_map(function (RouteParameter $parameter) { + return $this->parseRouteParameter($parameter); + }, $collection->parameters)); + } + + protected function parseRouteParameter(RouteParameter $parameter): TypeScriptNode + { + return new TypeScriptProperty( + $parameter->name, + new TypeScriptUnion([new TypeScriptString(), new TypeScriptNumber()]), + isOptional: $parameter->optional, + ); + } + + protected function routeCollectionToJson(RouteCollection $collection): string + { + $mappingFunction = fn (RouteInvokableController|RouteControllerAction|RouteClosure $entity) => [ + $entity->name => [ + 'url' => $entity->url, + 'methods' => array_values($entity->methods), + ], + ]; + + $controllers = collect($collection->controllers)->mapWithKeys(function (RouteController|RouteInvokableController $controller) use ($mappingFunction) { + if ($controller instanceof RouteInvokableController && $controller->name) { + return $mappingFunction($controller); + } + + if ($controller instanceof RouteController) { + return collect($controller->actions) + ->filter(fn (RouteControllerAction $action) => $action->name) + ->values() + ->mapWithKeys($mappingFunction); + } + + return []; + }); + + $closures = collect($collection->closures) + ->filter(fn (RouteClosure $closure) => $closure->name) + ->values() + ->mapWithKeys(function (RouteClosure $closure) use ($mappingFunction) { + return $mappingFunction($closure); + }); + + return $controllers->merge($closures)->toJson(JSON_UNESCAPED_SLASHES); + } +} diff --git a/src/Laravel/LaravelRouteActionTypesProvider.php b/src/Laravel/LaravelRouteActionTypesProvider.php new file mode 100644 index 0000000..6f8bd79 --- /dev/null +++ b/src/Laravel/LaravelRouteActionTypesProvider.php @@ -0,0 +1,279 @@ + $location + * @param array $filters + */ + public function __construct( + protected ResolveLaravelRoutControllerCollectionsAction $resolveLaravelRoutControllerCollectionsAction = new ResolveLaravelRoutControllerCollectionsAction(), + protected ?string $defaultNamespace = null, + protected array $location = ['App'], + protected array $filters = [], + ) { + } + + public function provide(TypeScriptTransformerConfig $config, TransformedCollection $types): void + { + $routeCollection = $this->resolveLaravelRoutControllerCollectionsAction->execute( + defaultNamespace: $this->defaultNamespace, + includeRouteClosures: false, + filters: $this->filters, + ); + + $transformedRoutes = new Transformed( + new TypeScriptAlias( + new TypeScriptIdentifier('ActionRoutesList'), + $this->parseRouteCollection($routeCollection), + ), + $routesListReference = new CustomReference('laravel_route_actions', 'routes_list'), + $this->location, + true, + ); + + $isInvokableControllerCondition = TypeScriptOperator::extends( + new TypeScriptIndexedAccess( + new TypeReference($routesListReference), + [new TypeScriptIdentifier('TController')], + ), + new TypeScriptObject([ + new TypeScriptProperty('invokable', new TypeScriptRaw('true')), + ]) + ); + + $actionController = new Transformed( + new TypeScriptAlias( + new TypeScriptGeneric( + new TypeScriptIdentifier('ActionController'), + [ + new TypeScriptGenericTypeVariable(new TypeScriptIdentifier('TController')), + new TypeScriptGenericTypeVariable(new TypeScriptIdentifier('TAction')), + ] + ), + new TypeScriptConditional( + $isInvokableControllerCondition, + new TypeScriptIdentifier('TController'), + new TypeScriptArray([ + new TypeScriptIdentifier('TController'), + new TypeScriptIdentifier('TAction'), + ]) + ) + ), + $actionControllerReference = new CustomReference('laravel_route_actions', 'action_controller'), + $this->location, + true, + ); + + $actionParameters = new Transformed( + new TypeScriptAlias( + new TypeScriptGeneric( + new TypeScriptIdentifier('ActionParameters'), + [ + new TypeScriptGenericTypeVariable(new TypeScriptIdentifier('TController')), + new TypeScriptGenericTypeVariable(new TypeScriptIdentifier('TAction')), + ] + ), + new TypeScriptConditional( + $isInvokableControllerCondition, + new TypeScriptIndexedAccess(new TypeReference($routesListReference), [ + new TypeScriptIdentifier('TController'), + new TypeScriptIdentifier('"parameters"'), + ]), + new TypeScriptIndexedAccess(new TypeReference($routesListReference), [ + new TypeScriptIdentifier('TController'), + new TypeScriptIdentifier('"actions"'), + new TypeScriptIdentifier('TAction'), + new TypeScriptIdentifier('"parameters"'), + ]) + ) + ), + $actionParametersReference = new CustomReference('laravel_route_actions', 'action_parameters'), + $this->location, + true, + ); + + $jsonEncodedRoutes = $this->routeCollectionToJson($routeCollection); + $baseUrl = url('/'); + + $transformedAction = new Transformed( + new TypeScriptFunctionDefinition( + new TypeScriptGeneric( + new TypeScriptIdentifier('action'), + [ + new TypeScriptGenericTypeVariable( + new TypeScriptIdentifier('TController'), + extends: TypeScriptOperator::keyof(new TypeReference($routesListReference)) + ), + new TypeScriptGenericTypeVariable( + new TypeScriptIdentifier('TAction'), + extends: TypeScriptOperator::keyof(new TypeScriptIndexedAccess(new TypeReference($routesListReference), [ + new TypeScriptIdentifier('TController'), + new TypeScriptIdentifier('"actions"'), + ])) + ), + new TypeScriptGenericTypeVariable( + new TypeScriptIdentifier('TParams'), + extends: new TypeScriptIndexedAccess(new TypeReference($routesListReference), [ + new TypeScriptIdentifier('TController'), + new TypeScriptIdentifier('"actions"'), + new TypeScriptIdentifier('TAction'), + new TypeScriptIdentifier('"parameters"'), + ]) + ), + ] + ), + [ + new TypeScriptParameter('action', new TypeScriptGeneric( + new TypeReference($actionControllerReference), + [ + new TypeScriptGenericTypeVariable(new TypeScriptIdentifier('TController')), + new TypeScriptGenericTypeVariable(new TypeScriptIdentifier('TAction')), + ] + )), + new TypeScriptParameter('parameters', new TypeScriptGeneric( + new TypeReference($actionParametersReference), + [ + new TypeScriptGenericTypeVariable(new TypeScriptIdentifier('TController')), + new TypeScriptGenericTypeVariable(new TypeScriptIdentifier('TAction')), + ] + ), isOptional: true), + ], + new TypeScriptString(), + new TypeScriptRaw( + <<location, + true, + ); + + $types->add($transformedRoutes, $actionController, $actionParameters, $transformedAction); + } + + protected function parseRouteCollection(RouteCollection $collection): TypeScriptNode + { + return new TypeScriptObject(collect($collection->controllers)->map(function (RouteController|RouteInvokableController $controller, string $name) { + return new TypeScriptProperty( + $name, + $controller instanceof RouteInvokableController + ? $this->parseInvokableController($controller) + : $this->parseController($controller), + ); + })->all()); + } + + protected function parseController(RouteController $controller): TypeScriptNode + { + return new TypeScriptObject([ + new TypeScriptProperty('actions', new TypeScriptObject(collect($controller->actions)->map(function (RouteControllerAction $action, string $name) { + return new TypeScriptProperty( + $name, + $this->parseControllerAction($action) + ); + })->all())), + ]); + } + + protected function parseControllerAction(RouteControllerAction $action): TypeScriptNode + { + return new TypeScriptObject([ + new TypeScriptProperty('parameters', $this->parseRouteParameterCollection($action->parameters)), + ]); + } + + protected function parseInvokableController(RouteInvokableController $controller): TypeScriptNode + { + return new TypeScriptObject([ + new TypeScriptProperty('invokable', new TypeScriptRaw('true')), + new TypeScriptProperty('parameters', $this->parseRouteParameterCollection($controller->parameters)), + ]); + } + + protected function parseRouteParameterCollection(RouteParameterCollection $collection): TypeScriptNode + { + return new TypeScriptObject(array_map(function (RouteParameter $parameter) { + return $this->parseRouteParameter($parameter); + }, $collection->parameters)); + } + + protected function parseRouteParameter(RouteParameter $parameter): TypeScriptNode + { + return new TypeScriptProperty( + $parameter->name, + new TypeScriptUnion([new TypeScriptString(), new TypeScriptNumber()]), + isOptional: $parameter->optional, + ); + } + + protected function routeCollectionToJson(RouteCollection $collection): string + { + return collect($collection->controllers) + ->map( + fn (RouteController|RouteInvokableController $controller) => $controller instanceof RouteInvokableController + ? [ + 'url' => $controller->url, + 'methods' => array_values($controller->methods), + ] + : [ + 'actions' => collect($controller->actions)->map(fn (RouteControllerAction $action) => [ + 'url' => $action->url, + 'methods' => array_values($action->methods), + ]), + ] + ) + ->toJson(JSON_UNESCAPED_SLASHES); + } +} diff --git a/src/Laravel/LaravelTypeScriptTransformerExtension.php b/src/Laravel/LaravelTypeScriptTransformerExtension.php new file mode 100644 index 0000000..2dde856 --- /dev/null +++ b/src/Laravel/LaravelTypeScriptTransformerExtension.php @@ -0,0 +1,24 @@ +replaceTransformer( + AttributedClassTransformer::class, + LaravelAttributedClassTransformer::class + ) + ->typesProvider(LaravelTypesProvider::class) + ->replaceType(CarbonInterface::class, new TypeScriptString()); + } +} diff --git a/src/Laravel/LaravelTypesProvider.php b/src/Laravel/LaravelTypesProvider.php new file mode 100644 index 0000000..bfc52c9 --- /dev/null +++ b/src/Laravel/LaravelTypesProvider.php @@ -0,0 +1,178 @@ +add( + $this->lengthAwarePaginator(), + $this->lengthAwarePaginatorInterface(), + $this->cursorPaginator(), + $this->cursorPaginatorInterface(), + ); + } + + protected function lengthAwarePaginator(): Transformed + { + return new Transformed( + new TypeScriptAlias( + new TypeScriptGeneric( + new TypeScriptIdentifier('LengthAwarePaginator'), + [new TypeScriptIdentifier('TKey'), new TypeScriptIdentifier('TValue')], + ), + new TypeScriptObject([ + new TypeScriptProperty('data', new TypeScriptGeneric( + new TypeScriptIdentifier('Record'), + [new TypeScriptIdentifier('TKey'), new TypeScriptIdentifier('TValue')], + ), ), + new TypeScriptProperty('links', new TypeScriptObject([ + new TypeScriptProperty('url', new TypeScriptUnion([ + new TypeScriptIdentifier('string'), + new TypeScriptIdentifier('null'), + ])), + new TypeScriptProperty('label', new TypeScriptString()), + new TypeScriptProperty('active', new TypeScriptBoolean()), + ])), + new TypeScriptProperty('meta', new TypeScriptObject([ + new TypeScriptProperty('total', new TypeScriptNumber()), + new TypeScriptProperty('current_page', new TypeScriptNumber()), + new TypeScriptProperty('first_page_url', new TypeScriptString()), + new TypeScriptProperty('from', new TypeScriptUnion([ + new TypeScriptNumber(), + new TypeScriptNull(), + ])), + new TypeScriptProperty('last_page', new TypeScriptNumber()), + new TypeScriptProperty('last_page_url', new TypeScriptString()), + new TypeScriptProperty('next_page_url', new TypeScriptUnion([ + new TypeScriptString(), + new TypeScriptNull(), + ])), + new TypeScriptProperty('path', new TypeScriptString()), + new TypeScriptProperty('per_page', new TypeScriptNumber()), + new TypeScriptProperty('prev_page_url', new TypeScriptUnion([ + new TypeScriptString(), + new TypeScriptNull(), + ])), + new TypeScriptProperty('to', new TypeScriptUnion([ + new TypeScriptNumber(), + new TypeScriptNull(), + ])), + ])), + ]), + ), + new ClassStringReference(LengthAwarePaginator::class), + ['Illuminate'], + true, + ); + } + + protected function lengthAwarePaginatorInterface(): Transformed + { + return new Transformed( + new TypeScriptAlias( + new TypeScriptGeneric( + new TypeScriptIdentifier('LengthAwarePaginatorInterface'), + [new TypeScriptIdentifier('T')], + ), + new TypeScriptGeneric( + new TypeReference(new ClassStringReference(LengthAwarePaginator::class)), + [new TypeScriptIdentifier('T')], + ), + ), + new ClassStringReference(LengthAwarePaginatorInterface::class), + ['Illuminate'], + true, + ); + } + + protected function cursorPaginator(): Transformed + { + return new Transformed( + new TypeScriptAlias( + new TypeScriptGeneric( + new TypeScriptIdentifier('CursorPaginator'), + [new TypeScriptIdentifier('TKey'), new TypeScriptIdentifier('TValue')], + ), + new TypeScriptObject([ + new TypeScriptProperty('data', new TypeScriptGeneric( + new TypeScriptIdentifier('Record'), + [new TypeScriptIdentifier('TKey'), new TypeScriptIdentifier('TValue')], + ), ), + new TypeScriptProperty('links', new TypeScriptObject([ + new TypeScriptProperty('url', new TypeScriptUnion([ + new TypeScriptIdentifier('string'), + new TypeScriptIdentifier('null'), + ])), + new TypeScriptProperty('label', new TypeScriptString()), + new TypeScriptProperty('active', new TypeScriptBoolean()), + ])), + new TypeScriptProperty('meta', new TypeScriptObject([ + new TypeScriptProperty('path', new TypeScriptString()), + new TypeScriptProperty('per_page', new TypeScriptNumber()), + new TypeScriptProperty('next_cursor', new TypeScriptUnion([ + new TypeScriptString(), + new TypeScriptNull(), + ])), + new TypeScriptProperty('next_page_url', new TypeScriptUnion([ + new TypeScriptString(), + new TypeScriptNull(), + ])), + new TypeScriptProperty('prev_cursor', new TypeScriptUnion([ + new TypeScriptString(), + new TypeScriptNull(), + ])), + new TypeScriptProperty('prev_page_url', new TypeScriptUnion([ + new TypeScriptString(), + new TypeScriptNull(), + ])), + ])), + ]), + ), + new ClassStringReference(CursorPaginator::class), + ['Illuminate'], + true, + ); + } + + protected function cursorPaginatorInterface(): Transformed + { + return new Transformed( + new TypeScriptAlias( + new TypeScriptGeneric( + new TypeScriptIdentifier('CursorPaginatorInterface'), + [new TypeScriptIdentifier('T')], + ), + new TypeScriptGeneric( + new TypeReference(new ClassStringReference(CursorPaginator::class)), + [new TypeScriptIdentifier('T')], + ), + ), + new ClassStringReference(CursorPaginatorInterface::class), + ['Illuminate'], + true, + ); + } +} diff --git a/src/Laravel/Routes/RouteClosure.php b/src/Laravel/Routes/RouteClosure.php new file mode 100644 index 0000000..812b5ba --- /dev/null +++ b/src/Laravel/Routes/RouteClosure.php @@ -0,0 +1,17 @@ + $methods + */ + public function __construct( + public RouteParameterCollection $parameters, + public array $methods, + public string $url, + public ?string $name, + ) { + } +} diff --git a/src/Laravel/Routes/RouteCollection.php b/src/Laravel/Routes/RouteCollection.php new file mode 100644 index 0000000..4a5bf5f --- /dev/null +++ b/src/Laravel/Routes/RouteCollection.php @@ -0,0 +1,16 @@ + $controllers + * @param array $closures + */ + public function __construct( + public array $controllers, + public array $closures, + ) { + } +} diff --git a/src/Laravel/Routes/RouteController.php b/src/Laravel/Routes/RouteController.php new file mode 100644 index 0000000..d082305 --- /dev/null +++ b/src/Laravel/Routes/RouteController.php @@ -0,0 +1,14 @@ + $actions + */ + public function __construct( + public array $actions, + ) { + } +} diff --git a/src/Laravel/Routes/RouteControllerAction.php b/src/Laravel/Routes/RouteControllerAction.php new file mode 100644 index 0000000..2d1d355 --- /dev/null +++ b/src/Laravel/Routes/RouteControllerAction.php @@ -0,0 +1,17 @@ + $methods + */ + public function __construct( + public RouteParameterCollection $parameters, + public array $methods, + public string $url, + public ?string $name, + ) { + } +} diff --git a/src/Laravel/Routes/RouteInvokableController.php b/src/Laravel/Routes/RouteInvokableController.php new file mode 100644 index 0000000..2edbaa5 --- /dev/null +++ b/src/Laravel/Routes/RouteInvokableController.php @@ -0,0 +1,17 @@ + $methods + */ + public function __construct( + public RouteParameterCollection $parameters, + public array $methods, + public string $url, + public ?string $name, + ) { + } +} diff --git a/src/Laravel/Routes/RouteParameter.php b/src/Laravel/Routes/RouteParameter.php new file mode 100644 index 0000000..adc3511 --- /dev/null +++ b/src/Laravel/Routes/RouteParameter.php @@ -0,0 +1,12 @@ + $parameters + */ + public function __construct( + public array $parameters, + ) { + } +} diff --git a/src/Laravel/Routes/RouterStructure.php b/src/Laravel/Routes/RouterStructure.php new file mode 100644 index 0000000..c9036f1 --- /dev/null +++ b/src/Laravel/Routes/RouterStructure.php @@ -0,0 +1,7 @@ +add($optionsType); + } + } +} diff --git a/src/Laravel/Support/WithoutRoutes.php b/src/Laravel/Support/WithoutRoutes.php new file mode 100644 index 0000000..527f8c2 --- /dev/null +++ b/src/Laravel/Support/WithoutRoutes.php @@ -0,0 +1,65 @@ +closure)($route); + } + + public static function satisfying(Closure $closure): self + { + return new static($closure); + } + + public static function named(string ...$names): self + { + return new self(function (Route $route) use ($names): bool { + if ($route->getName() === null) { + return false; + } + + foreach ($names as $name) { + if (Str::is($name, $route->getName())) { + return true; + } + } + + return false; + }); + } + + public static function controller(string|array ...$controllers): self + { + return new self(function (Route $route) use ($controllers): bool { + if ($route->getControllerClass() === null) { + return false; + } + + foreach ($controllers as $controller) { + if (is_string($controller) && Str::is($controller, $route->getControllerClass())) { + return true; + } + + if (is_array($controller) + && Str::is($controller[0], $route->getControllerClass()) + && Str::is($controller[1], $route->getActionMethod()) + ) { + return true; + } + } + + return false; + }); + } +} diff --git a/src/Laravel/Support/WrappedLaravelConsole.php b/src/Laravel/Support/WrappedLaravelConsole.php new file mode 100644 index 0000000..e70351e --- /dev/null +++ b/src/Laravel/Support/WrappedLaravelConsole.php @@ -0,0 +1,34 @@ +command->error($message); + } + + public function info(string $message): void + { + $this->command->info($message); + } + + public function warn(string $message): void + { + $this->command->warn($message); + } + + public function exit(int $code = 0): void + { + exit($code); + } +} diff --git a/src/Laravel/Transformers/DataClassTransformer.php b/src/Laravel/Transformers/DataClassTransformer.php new file mode 100644 index 0000000..95d3414 --- /dev/null +++ b/src/Laravel/Transformers/DataClassTransformer.php @@ -0,0 +1,54 @@ +dataConfig = app(DataConfig::class); + + parent::__construct($docTypeResolver, $transpilePhpStanTypeToTypeScriptTypeAction, $transpilePhpTypeNodeToTypeScriptTypeAction); + } + + protected function shouldTransform(PhpClassNode $phpClassNode): bool + { + return $phpClassNode->implementsInterface(BaseData::class); + } + + protected function classPropertyProcessors(): array + { + return [ + new DataClassPropertyProcessor( + $this->dataConfig, + $this->customLazyTypes, + ), + new FixArrayLikeStructuresClassPropertyProcessor( + replaceArrays: true, + arrayLikeClassesToReplace: [ + \Illuminate\Support\Collection::class, + \Illuminate\Database\Eloquent\Collection::class, + \Spatie\LaravelData\DataCollection::class, + ...$this->customDataCollections, + ] + ), + ]; + } +} diff --git a/src/Laravel/Transformers/LaravelAttributedClassTransformer.php b/src/Laravel/Transformers/LaravelAttributedClassTransformer.php new file mode 100644 index 0000000..9d44a63 --- /dev/null +++ b/src/Laravel/Transformers/LaravelAttributedClassTransformer.php @@ -0,0 +1,25 @@ +replaceArrayLikeClass( + \Illuminate\Support\Collection::class, + \Illuminate\Database\Eloquent\Collection::class, + ); + } + } + + return $processors; + } +} diff --git a/src/Laravel/TypeScriptTransformerApplicationServiceProvider.php b/src/Laravel/TypeScriptTransformerApplicationServiceProvider.php new file mode 100644 index 0000000..5849064 --- /dev/null +++ b/src/Laravel/TypeScriptTransformerApplicationServiceProvider.php @@ -0,0 +1,23 @@ +app->singleton(TypeScriptTransformerConfig::class, function () { + $builder = new TypeScriptTransformerConfigFactory(); + + $this->configure($builder); + + return $builder->get(); + }); + } +} diff --git a/src/Laravel/TypeScriptTransformerServiceProvider.php b/src/Laravel/TypeScriptTransformerServiceProvider.php new file mode 100644 index 0000000..9c10038 --- /dev/null +++ b/src/Laravel/TypeScriptTransformerServiceProvider.php @@ -0,0 +1,30 @@ +name('typescript-transformer') + ->hasCommand(WatchTypeScriptCommand::class) + ->hasCommand(TransformTypeScriptCommand::class) + ->hasCommand(InstallTypeScriptTransformerCommand::class); + } + + public function bootingPackage(): void + { + if ($this->app->runningInConsole()) { + $this->publishes([ + __DIR__.'/../../stubs/TypeScriptTransformerServiceProvider.stub' => app_path('Providers/TypeScriptTransformerServiceProvider.php'), + ], 'typescript-transformer-provider'); + } + } +} diff --git a/src/PhpNodes/PhpAttributeNode.php b/src/PhpNodes/PhpAttributeNode.php new file mode 100644 index 0000000..cc50ae9 --- /dev/null +++ b/src/PhpNodes/PhpAttributeNode.php @@ -0,0 +1,97 @@ +reflection->getName(); + } + + public function getArguments(): array + { + return $this->reflection->getArguments(); + } + + public function hasArgument(string $name): bool + { + if ($this->arguments === null) { + $this->initializeArguments(); + } + + return array_key_exists($name, $this->arguments); + } + + public function getArgument(string $name): mixed + { + if ($this->arguments === null) { + $this->initializeArguments(); + } + + return $this->arguments[$name] ?? null; + } + + public function newInstance(): object + { + if ($this->reflection instanceof ReflectionAttribute) { + return $this->reflection->newInstance(); + } + + $className = $this->reflection->getName(); + + // TODO: maybe we can do a little better here + return (new $className())($this->reflection->getArguments()); + } + + /** @return array */ + protected function initializeArguments(): array + { + // TODO: this is a quickly written thing, test it to be sure it works + if ($this->arguments !== null) { + return $this->arguments; + } + + $this->arguments = []; + + $values = $this->getArguments(); + + foreach ($values as $name => $value) { + if (is_string($name)) { + $this->arguments[$name] = $value; + unset($values[$name]); + } + } + + if (count($values) === 0) { + return $this->arguments; + } + + $constructor = new ReflectionMethod($this->reflection->getName(), '__construct'); + + foreach ($constructor->getParameters() as $index => $param) { + if (array_key_exists($param->getName(), $this->arguments)) { + continue; + } + + if (! array_key_exists($index, $values)) { + continue; + } + + $this->arguments[$param->getName()] = $values[$index]; + } + + return $this->arguments; + } +} diff --git a/src/PhpNodes/PhpClassNode.php b/src/PhpNodes/PhpClassNode.php new file mode 100644 index 0000000..7f3c544 --- /dev/null +++ b/src/PhpNodes/PhpClassNode.php @@ -0,0 +1,145 @@ +isEnum()) { + return new PhpEnumNode(new ReflectionEnum($reflection->name)); + } + + return new self($reflection); + } + + /** + * @return array + */ + public function getAttributes(?string $name = null): array + { + $attributes = match (true) { + $this->reflection instanceof ReflectionClass => $this->reflection->getAttributes($name), + $name === null => $this->reflection->getAttributes(), + default => $this->reflection->getAttributesByInstance($name), + }; + + return array_map( + fn (ReflectionAttribute|RoaveReflectionAttribute $attribute) => new PhpAttributeNode($attribute), + $attributes, + ); + } + + public function getProperties(?int $filter = null): array + { + return array_map( + fn (ReflectionProperty|RoaveReflectionProperty $property) => new PhpPropertyNode($property), + $this->reflection->getProperties($filter), + ); + } + + public function getMethods(?int $filter = null): array + { + return array_map( + fn (ReflectionMethod|RoaveReflectionMethod $method) => new PhpMethodNode($method), + $this->reflection->getMethods($filter), + ); + } + + public function getShortName(): string + { + return $this->reflection->getShortName(); + } + + public function getName(): string + { + return $this->reflection->getName(); + } + + public function getNamespaceName(): string + { + return $this->reflection->getNamespaceName(); + } + + public function getFileName(): string + { + return $this->reflection->getFileName(); + } + + public function inNamespace(): bool + { + return $this->reflection->inNamespace(); + } + + public function implementsInterface(string $interface): bool + { + return $this->reflection->implementsInterface($interface); + } + + public function isEnum(): bool + { + return $this->reflection->isEnum(); + } + + public function isAbstract(): bool + { + return $this->reflection->isAbstract(); + } + + public function isFinal(): bool + { + return $this->reflection->isFinal(); + } + + public function isInterface(): bool + { + return $this->reflection->isInterface(); + } + + public function isReadonly(): bool + { + return $this->reflection->isReadonly(); + } + + public function getDocComment(): ?string + { + return $this->reflection->getDocComment() ?: null; + } + + public function hasMethod(string $name): bool + { + return $this->reflection->hasMethod($name); + } + + public function getMethod(string $name): ?PhpMethodNode + { + $method = $this->reflection->getMethod($name); + + return $method ? new PhpMethodNode($method) : null; + } +} diff --git a/src/PhpNodes/PhpEnumCaseNode.php b/src/PhpNodes/PhpEnumCaseNode.php new file mode 100644 index 0000000..1f1fa3f --- /dev/null +++ b/src/PhpNodes/PhpEnumCaseNode.php @@ -0,0 +1,33 @@ +reflection->getName(); + } + + public function getValue(): string|int|null + { + if ($this->reflection instanceof ReflectionEnumCase) { + return $this->reflection->getValue(); + } + + if (! method_exists($this->reflection, 'getBackingValue')) { + return null; + } + + return $this->reflection->getBackingValue(); + } +} diff --git a/src/PhpNodes/PhpEnumNode.php b/src/PhpNodes/PhpEnumNode.php new file mode 100644 index 0000000..fc19a97 --- /dev/null +++ b/src/PhpNodes/PhpEnumNode.php @@ -0,0 +1,36 @@ +reflection->isBacked(); + } + + /** + * @return PhpEnumCaseNode[] + */ + public function getCases(): array + { + return array_map( + fn (ReflectionEnumCase|ReflectionEnumUnitCase|ReflectionEnumBackedCase $case) => new PhpEnumCaseNode($case), + $this->reflection->getCases(), + ); + } +} diff --git a/src/PhpNodes/PhpIntersectionTypeNode.php b/src/PhpNodes/PhpIntersectionTypeNode.php new file mode 100644 index 0000000..4065e47 --- /dev/null +++ b/src/PhpNodes/PhpIntersectionTypeNode.php @@ -0,0 +1,24 @@ + PhpTypeNode::fromReflection($type), $this->reflection->getTypes()); + } +} diff --git a/src/PhpNodes/PhpMethodNode.php b/src/PhpNodes/PhpMethodNode.php new file mode 100644 index 0000000..bb0a2a2 --- /dev/null +++ b/src/PhpNodes/PhpMethodNode.php @@ -0,0 +1,45 @@ +reflection->getDocComment() ?: null; + } + + public function getName(): string + { + return $this->reflection->getName(); + } + + public function getReturnType(): ?PhpTypeNode + { + $type = $this->reflection->getReturnType(); + + if ($type === null) { + return null; + } + + return PhpTypeNode::fromReflection($type); + } + + public function getParameters(): array + { + return array_map( + fn (ReflectionParameter|RoaveReflectionParameter $parameter) => new PhpParameterNode($parameter), + $this->reflection->getParameters(), + ); + } +} diff --git a/src/PhpNodes/PhpNamedTypeNode.php b/src/PhpNodes/PhpNamedTypeNode.php new file mode 100644 index 0000000..0764790 --- /dev/null +++ b/src/PhpNodes/PhpNamedTypeNode.php @@ -0,0 +1,22 @@ +reflection->getName(); + } +} diff --git a/src/PhpNodes/PhpParameterNode.php b/src/PhpNodes/PhpParameterNode.php new file mode 100644 index 0000000..ffcbe46 --- /dev/null +++ b/src/PhpNodes/PhpParameterNode.php @@ -0,0 +1,40 @@ +reflection->getName(); + } + + public function hasType(): bool + { + return $this->reflection->hasType(); + } + + public function getType(): ?PhpTypeNode + { + $type = $this->reflection->getType(); + + if ($type === null) { + return null; + } + + return PhpTypeNode::fromReflection($type); + } + + public function isOptional(): bool + { + return $this->reflection->isOptional(); + } +} diff --git a/src/PhpNodes/PhpPropertyNode.php b/src/PhpNodes/PhpPropertyNode.php new file mode 100644 index 0000000..04f04e1 --- /dev/null +++ b/src/PhpNodes/PhpPropertyNode.php @@ -0,0 +1,74 @@ +reflection->getName(); + } + + public function getDeclaringClass(): PhpClassNode + { + return new PhpClassNode($this->reflection->getDeclaringClass()); + } + + /** + * @return array + */ + public function getAttributes(?string $name = null): array + { + $attributes = match (true) { + $this->reflection instanceof ReflectionProperty => $this->reflection->getAttributes($name), + $name === null => $this->reflection->getAttributes(), + default => $this->reflection->getAttributesByInstance($name), + }; + + return array_map( + fn (ReflectionAttribute|RoaveReflectionAttribute $attribute) => new PhpAttributeNode($attribute), + $attributes, + ); + } + + public function isStatic(): bool + { + return $this->reflection->isStatic(); + } + + public function hasType(): bool + { + return $this->reflection->hasType(); + } + + public function getType(): ?PhpTypeNode + { + $type = $this->reflection->getType(); + + if ($type === null) { + return null; + } + + return PhpTypeNode::fromReflection($type); + } + + public function isReadonly(): bool + { + return $this->reflection->isReadonly(); + } + + public function getDocComment(): ?string + { + return $this->reflection->getDocComment() ?: null; + } +} diff --git a/src/PhpNodes/PhpTypeNode.php b/src/PhpNodes/PhpTypeNode.php new file mode 100644 index 0000000..c705568 --- /dev/null +++ b/src/PhpNodes/PhpTypeNode.php @@ -0,0 +1,35 @@ + new PhpNamedTypeNode($reflection), + ReflectionUnionType::class, RoaveReflectionUnionType::class => new PhpUnionTypeNode($reflection), + ReflectionIntersectionType::class, RoaveReflectionIntersectionType::class => new PhpIntersectionTypeNode($reflection), + }; + } + + public function allowsNull(): bool + { + return $this->reflection->allowsNull(); + } +} diff --git a/src/PhpNodes/PhpUnionTypeNode.php b/src/PhpNodes/PhpUnionTypeNode.php new file mode 100644 index 0000000..a570e56 --- /dev/null +++ b/src/PhpNodes/PhpUnionTypeNode.php @@ -0,0 +1,24 @@ + PhpTypeNode::fromReflection($type), $this->reflection->getTypes()); + } +} diff --git a/src/References/ClassStringReference.php b/src/References/ClassStringReference.php new file mode 100644 index 0000000..e761392 --- /dev/null +++ b/src/References/ClassStringReference.php @@ -0,0 +1,24 @@ +classString = trim($classString, '\\'); + } + + public function getKey(): string + { + return "class_string_{$this->classString}"; + } + + public function humanFriendlyName(): string + { + return "class {$this->classString}"; + } +} diff --git a/src/References/CustomReference.php b/src/References/CustomReference.php new file mode 100644 index 0000000..b2cbedc --- /dev/null +++ b/src/References/CustomReference.php @@ -0,0 +1,22 @@ +group}_{$this->name}"; + } + + public function humanFriendlyName(): string + { + return "custom {$this->group}::{$this->name}"; + } +} diff --git a/src/References/FilesystemReference.php b/src/References/FilesystemReference.php new file mode 100644 index 0000000..7e7b13a --- /dev/null +++ b/src/References/FilesystemReference.php @@ -0,0 +1,8 @@ +name}"; + } + + public function humanFriendlyName(): string + { + return "function {$this->name}"; + } +} diff --git a/src/References/PhpClassReference.php b/src/References/PhpClassReference.php new file mode 100644 index 0000000..cbe93a1 --- /dev/null +++ b/src/References/PhpClassReference.php @@ -0,0 +1,19 @@ +getName()); + } + + public function getFilesystemOriginPath(): string + { + return $this->phpClassNode->getFileName(); + } +} diff --git a/src/References/Reference.php b/src/References/Reference.php new file mode 100644 index 0000000..2e77d0e --- /dev/null +++ b/src/References/Reference.php @@ -0,0 +1,10 @@ +missingSymbols; - } - - public function remove(string $symbol) - { - if (in_array($symbol, $this->missingSymbols)) { - unset($this->missingSymbols[array_search($symbol, $this->missingSymbols)]); - } - } - - public function isEmpty(): bool - { - return empty($this->missingSymbols); - } - - public function add(string $symbol): string - { - $symbol = ltrim($symbol, '\\'); - - if (! in_array($symbol, $this->missingSymbols)) { - $this->missingSymbols[] = $symbol; - } - - return "{%{$symbol}%}"; - } -} diff --git a/src/Structures/TransformedType.php b/src/Structures/TransformedType.php deleted file mode 100644 index 49322a3..0000000 --- a/src/Structures/TransformedType.php +++ /dev/null @@ -1,111 +0,0 @@ -reflection = $class; - $this->name = $name; - $this->transformed = $transformed; - $this->missingSymbols = $missingSymbols; - $this->isInline = $isInline; - $this->keyword = $keyword; - $this->trailingSemicolon = $trailingSemicolon; - } - - public function getNamespaceSegments(): array - { - if ($this->isInline === true) { - return []; - } - - $namespace = $this->reflection->getNamespaceName(); - - if (empty($namespace)) { - return []; - } - - return explode('\\', $namespace); - } - - public function getTypeScriptName($fullyQualified = true): string - { - if (! $fullyQualified) { - return $this->name ?? ''; - } - - $segments = array_merge( - $this->getNamespaceSegments(), - [$this->name] - ); - - return implode('.', $segments); - } - - public function replaceSymbol(string $class, string $replacement): void - { - $this->missingSymbols->remove($class); - - $this->transformed = str_replace( - "{%{$class}%}", - $replacement, - $this->transformed - ); - } - - public function toString(): string - { - $output = match ($this->keyword) { - 'enum' => "enum {$this->name} { {$this->transformed} }", - 'interface' => "interface {$this->name} {$this->transformed}", - default => "type {$this->name} = {$this->transformed}", - }; - - return $output . ($this->trailingSemicolon ? ';' : ''); - } -} diff --git a/src/Structures/TypesCollection.php b/src/Structures/TypesCollection.php deleted file mode 100644 index d175833..0000000 --- a/src/Structures/TypesCollection.php +++ /dev/null @@ -1,106 +0,0 @@ -types); - } - - public function offsetGet($class): ?TransformedType - { - return $this->types[$class] ?? null; - } - - public function getIterator(): ArrayIterator - { - return new ArrayIterator($this->types); - } - - /** - * @param null|string|\Spatie\TypeScriptTransformer\Structures\TransformedType $class - * @param \Spatie\TypeScriptTransformer\Structures\TransformedType $type - * - * @throws \Spatie\TypeScriptTransformer\Exceptions\SymbolAlreadyExists - */ - public function offsetSet($class, $type): void - { - $class ??= $type->reflection->getName(); - - $class = $class instanceof TransformedType - ? $class->reflection->getName() - : $class; - - if (array_key_exists($class, $this->types) === false && $type->isInline === false) { - $this->ensureTypeCanBeAdded($type); - } - - $this->types[$class] = $type; - } - - public function offsetUnset($class): void - { - unset($this->types[$class]); - } - - public function count(): int - { - return count($this->types); - } - - protected function ensureTypeCanBeAdded(TransformedType $type): void - { - $namespace = array_reduce($type->getNamespaceSegments(), function (array $checkedSegments, string $segment) { - $segments = array_merge($checkedSegments, [$segment]); - - $namespace = join('.', $segments); - - if (array_key_exists($namespace, $this->structure)) { - if ($this->structure[$namespace]['kind'] !== 'namespace') { - throw SymbolAlreadyExists::whenAddingNamespace( - $namespace, - $this->structure[$namespace] - ); - } - } - - $this->structure[$namespace] = [ - 'kind' => 'namespace', - 'value' => str_replace('.', '\\', $namespace), - ]; - - return $segments; - }, []); - - $namespacedType = join('.', array_merge($namespace, [$type->name])); - - if (array_key_exists($namespacedType, $this->structure)) { - throw SymbolAlreadyExists::whenAddingType( - $type->reflection->getName(), - $this->structure[$namespacedType] - ); - } - - $this->structure[$namespacedType] = [ - 'kind' => 'type', - 'value' => $type->reflection->getName(), - ]; - } -} diff --git a/src/Support/Concerns/Instanceable.php b/src/Support/Concerns/Instanceable.php new file mode 100644 index 0000000..abee350 --- /dev/null +++ b/src/Support/Concerns/Instanceable.php @@ -0,0 +1,13 @@ + */ + public array $messages = []; + + public function error(string $message): void + { + $this->messages[] = ['message' => $message, 'level' => 'error']; + } + + public function info(string $message): void + { + $this->messages[] = ['message' => $message, 'level' => 'info']; + } + + public function warn(string $message): void + { + $this->messages[] = ['message' => $message, 'level' => 'warning']; + } + + public function exit(int $code = 0): void + { + die($code); + } +} diff --git a/src/Support/Console/WrappedConsole.php b/src/Support/Console/WrappedConsole.php new file mode 100644 index 0000000..e38bf7d --- /dev/null +++ b/src/Support/Console/WrappedConsole.php @@ -0,0 +1,14 @@ +alias === null) { + return $this->name; + } + + return "{$this->name} as {$this->alias}"; + } + + public function isAliased(): bool + { + return $this->alias !== null; + } +} diff --git a/src/Support/LoadPhpClassNodeAction.php b/src/Support/LoadPhpClassNodeAction.php new file mode 100644 index 0000000..15f5517 --- /dev/null +++ b/src/Support/LoadPhpClassNodeAction.php @@ -0,0 +1,53 @@ +astLocator = (new BetterReflection())->astLocator(); + $this->autoSourceLocator = new AutoloadSourceLocator($this->astLocator); + $this->phpInternalSourceLocator = new PhpInternalSourceLocator($this->astLocator, new ReflectionSourceStubber()); + } + + public function execute( + string $path + ): ?PhpClassNode { + $reflector = $this->resolveReflector($path); + + $classes = $reflector->reflectAllClasses(); + + if (count($classes) === 1) { + return PhpClassNode::fromReflection($classes[0]); + } + + return null; + } + + + protected function resolveReflector(string $path): DefaultReflector + { + return new DefaultReflector(new AggregateSourceLocator([ + new SingleFileSourceLocator($path, $this->astLocator), + $this->autoSourceLocator, + $this->phpInternalSourceLocator, + ])); + } +} diff --git a/src/Support/Location.php b/src/Support/Location.php new file mode 100644 index 0000000..9539222 --- /dev/null +++ b/src/Support/Location.php @@ -0,0 +1,46 @@ + $segments + * @param array $transformed + */ + public function __construct( + public array $segments, + public array $transformed, + ) { + } + + public function getTransformedByReference(Reference $reference): ?Transformed + { + foreach ($this->transformed as $transformed) { + if ($transformed->reference->getKey() === $reference->getKey()) { + return $transformed; + } + } + + return null; + } + + public function hasChanges(): bool + { + foreach ($this->transformed as $transformed) { + if ($transformed->changed) { + return true; + } + } + + return false; + } + + public function hasReference(Reference $reference): bool + { + return $this->getTransformedByReference($reference) !== null; + } +} diff --git a/src/Support/TransformationContext.php b/src/Support/TransformationContext.php new file mode 100644 index 0000000..8543f2e --- /dev/null +++ b/src/Support/TransformationContext.php @@ -0,0 +1,37 @@ +getAttributes(TypeScript::class)[0] ?? null; + + $name = $attribute && $attribute->hasArgument('name') + ? $attribute->getArgument('name') + : $node->getShortName(); + + $nameSpaceSegments = $attribute && $attribute->hasArgument('location') + ? $attribute->getArgument('location') + : explode('\\', $node->getNamespaceName()); + + return new TransformationContext( + $name, + $nameSpaceSegments, + count($node->getAttributes(Optional::class)) > 0, + ); + } +} diff --git a/src/Support/TypeScriptTransformerLog.php b/src/Support/TypeScriptTransformerLog.php new file mode 100644 index 0000000..081af5b --- /dev/null +++ b/src/Support/TypeScriptTransformerLog.php @@ -0,0 +1,51 @@ +wrappedConsole->info($message); + + return $this; + } + + public function warning(string $message): self + { + $this->wrappedConsole->warn($message); + + return $this; + } + + public function error(string $message): self + { + $this->wrappedConsole->error($message); + + return $this; + } +} diff --git a/src/Support/VisitorProfile.php b/src/Support/VisitorProfile.php new file mode 100644 index 0000000..43d0ee0 --- /dev/null +++ b/src/Support/VisitorProfile.php @@ -0,0 +1,31 @@ +singleNodes, ...$nodes); + + return $this; + } + + public function iterable(string ...$nodes): self + { + array_push($this->iterableNodes, ...$nodes); + + return $this; + } +} diff --git a/src/Support/WriteableFile.php b/src/Support/WriteableFile.php new file mode 100644 index 0000000..ad5948b --- /dev/null +++ b/src/Support/WriteableFile.php @@ -0,0 +1,12 @@ + */ + public array $references = []; + + /** @var array */ + public array $referencedBy = []; + + /** @var array */ + public array $missingReferences = []; + + /** + * @param array $location + */ + public function __construct( + public TypeScriptNode $typeScriptNode, + public Reference $reference, + public array $location, + public bool $export = true, + ) { + } + + public function getName(): ?string + { + if (isset($this->name)) { + return $this->name; + } + + if ($this->typeScriptNode instanceof TypeScriptNamedNode) { + return $this->name = $this->typeScriptNode->getName(); + } + + if ($this->typeScriptNode instanceof TypeScriptForwardingNamedNode) { + $exportableNode = $this->typeScriptNode; + + while ($exportableNode instanceof TypeScriptForwardingNamedNode) { + $exportableNode = $exportableNode->getForwardedNamedNode(); + } + + return $this->name = $exportableNode->getName(); + } + + return null; + } + + public function nameAs(string $name): self + { + $this->name = $name; + + return $this; + } + + public function prepareForWrite(): TypeScriptNode + { + $this->changed = false; + + if ($this->export === false) { + return $this->typeScriptNode; + } + + if (! $this->typeScriptNode instanceof TypeScriptNamedNode && ! $this->typeScriptNode instanceof TypeScriptForwardingNamedNode) { + return $this->typeScriptNode; + } + + return new TypeScriptExport($this->typeScriptNode); + } + + public function addMissingReference( + string|Reference $key, + TypeReference $typeReference + ): void { + if ($key instanceof Reference) { + $key = $key->getKey(); + } + + if (! array_key_exists($key, $this->missingReferences)) { + $this->missingReferences[$key] = []; + } + + $this->missingReferences[$key][] = $typeReference; + } + + public function markMissingReferenceFound( + Transformed $transformed + ): void { + $key = $transformed->reference->getKey(); + + $typeReferences = $this->missingReferences[$key]; + + foreach ($typeReferences as $typeReference) { + $typeReference->connect($transformed); + } + + $this->references[$key] = $typeReferences; + + unset($this->missingReferences[$key]); + + $this->markAsChanged(); + + $transformed->referencedBy[] = $this->reference->getKey(); + } + + public function markReferenceMissing( + Transformed $transformed + ): void { + $key = $transformed->reference->getKey(); + + $typeReferences = $this->references[$key]; + + foreach ($typeReferences as $typeReference) { + $typeReference->unconnect(); + } + + unset($this->references[$key]); + + $this->missingReferences[$key] = $typeReferences; + + $this->markAsChanged(); + } + + public function markAsChanged(): void + { + $this->changed = true; + } +} diff --git a/src/Transformed/Untransformable.php b/src/Transformed/Untransformable.php new file mode 100644 index 0000000..1cd372a --- /dev/null +++ b/src/Transformed/Untransformable.php @@ -0,0 +1,17 @@ +getAttributes(TypeScript::class)) > 0; + } + + public function transform(PhpClassNode $phpClassNode, TransformationContext $context): Transformed|Untransformable + { + $transformed = parent::transform($phpClassNode, $context); + + if ($transformed instanceof Untransformable) { + return $transformed; + } + + /** @var TypeScript $attribute */ + $attribute = $phpClassNode->getAttributes(TypeScript::class)[0]->getArguments(); + + if (($attribute['name'] ?? null) !== null) { + $transformed->nameAs($attribute['name']); + } + + if (($attribute['location'] ?? null) !== null) { + $transformed->location = $attribute['location']; + } + + return $transformed; + } +} diff --git a/src/Transformers/ClassPropertyProcessors/ClassPropertyProcessor.php b/src/Transformers/ClassPropertyProcessors/ClassPropertyProcessor.php new file mode 100644 index 0000000..6515093 --- /dev/null +++ b/src/Transformers/ClassPropertyProcessors/ClassPropertyProcessor.php @@ -0,0 +1,16 @@ +classPropertyProcessors = $this->classPropertyProcessors(); + } + + public function transform(PhpClassNode $phpClassNode, TransformationContext $context): Transformed|Untransformable + { + if ($phpClassNode->isEnum() || $phpClassNode->isInterface()) { + return Untransformable::create(); + } + + if (! $this->shouldTransform($phpClassNode)) { + return Untransformable::create(); + } + + return new Transformed( + new TypeScriptAlias( + new TypeScriptIdentifier($context->name), + $this->getTypeScriptNode($phpClassNode, $context) + ), + new PhpClassReference($phpClassNode), + $context->nameSpaceSegments, + true, + ); + } + + abstract protected function shouldTransform(PhpClassNode $phpClassNode): bool; + + /** @return array */ + protected function classPropertyProcessors(): array + { + return [ + new FixArrayLikeStructuresClassPropertyProcessor(), + ]; + } + + protected function getTypeScriptNode( + PhpClassNode $phpClassNode, + TransformationContext $context, + ): TypeScriptNode { + if ($resolvedAttributeType = $this->resolveTypeByAttribute($phpClassNode)) { + return $resolvedAttributeType; + } + + $classAnnotations = $this->docTypeResolver->class($phpClassNode)?->properties ?? []; + + $constructorAnnotations = $phpClassNode->hasMethod('__construct') + ? $this->docTypeResolver->method($phpClassNode->getMethod('__construct'))?->parameters ?? [] + : []; + + $properties = []; + + foreach ($this->getProperties($phpClassNode) as $phpPropertyNode) { + $annotation = $classAnnotations[$phpPropertyNode->getName()] + ?? $constructorAnnotations[$phpPropertyNode->getName()] + ?? $this->docTypeResolver->property($phpPropertyNode) + ?? null; + + $property = $this->createProperty( + $phpClassNode, + $phpPropertyNode, + $annotation?->type, + $context + ); + + if ($property === null) { + continue; + } + + $property = $this->runClassPropertyProcessors( + $phpPropertyNode, + $annotation?->type, + $property + ); + + if ($property !== null) { + $properties[] = $property; + } + } + + return new TypeScriptObject($properties); + } + + protected function resolveTypeByAttribute( + PhpClassNode $phpClassNode, + ?PhpPropertyNode $property = null, + ): ?TypeScriptNode { + $subject = $property ?? $phpClassNode; + + foreach ($subject->getAttributes() as $attribute) { + if (is_a($attribute->getName(), TypeScriptTypeAttributeContract::class, true)) { + /** @var TypeScriptTypeAttributeContract $attributeInstance */ + $attributeInstance = $attribute->newInstance(); + + return $attributeInstance->getType($phpClassNode); + } + } + + return null; + } + + protected function getProperties(PhpClassNode $phpClassNode): array + { + return array_filter( + $phpClassNode->getProperties(\ReflectionProperty::IS_PUBLIC), + fn (PhpPropertyNode $property) => ! $property->isStatic() + ); + } + + protected function createProperty( + PhpClassNode $phpClassNode, + PhpPropertyNode $phpPropertyNode, + ?TypeNode $annotation, + TransformationContext $context, + ): ?TypeScriptProperty { + $type = $this->resolveTypeForProperty( + $phpClassNode, + $phpPropertyNode, + $annotation + ); + + $property = new TypeScriptProperty( + $phpPropertyNode->getName(), + $type, + $this->isPropertyOptional( + $phpPropertyNode, + $phpClassNode, + $type, + $context + ), + $this->isPropertyReadonly( + $phpPropertyNode, + $phpClassNode, + $type, + ) + ); + + if ($this->isPropertyHidden($phpPropertyNode, $phpClassNode, $property)) { + return null; + } + + return $property; + } + + protected function resolveTypeForProperty( + PhpClassNode $phpClassNode, + PhpPropertyNode $phpPropertyNode, + ?TypeNode $annotation, + ): TypeScriptNode { + if ($resolvedAttributeType = $this->resolveTypeByAttribute($phpClassNode, $phpPropertyNode)) { + return $resolvedAttributeType; + } + + if ($annotation) { + return $this->transpilePhpStanTypeToTypeScriptTypeAction->execute( + $annotation, + $phpClassNode, + ); + } + + if ($phpPropertyNode->hasType()) { + return $this->transpilePhpTypeNodeToTypeScriptTypeAction->execute( + $phpPropertyNode->getType(), + $phpClassNode + ); + } + + return new TypeScriptUnknown(); + } + + protected function isPropertyOptional( + PhpPropertyNode $phpPropertyNode, + PhpClassNode $phpClassNode, + TypeScriptNode $type, + TransformationContext $context, + ): bool { + return $context->optional || count($phpPropertyNode->getAttributes(Optional::class)) > 0; + } + + protected function isPropertyReadonly( + PhpPropertyNode $phpPropertyNode, + PhpClassNode $phpClassNode, + TypeScriptNode $type, + ): bool { + return $phpPropertyNode->isReadOnly() || $phpClassNode->isReadOnly(); + } + + protected function isPropertyHidden( + PhpPropertyNode $phpPropertyNode, + PhpClassNode $phpClassNode, + TypeScriptProperty $property, + ): bool { + return count($phpPropertyNode->getAttributes(Hidden::class)) > 0; + } + + protected function runClassPropertyProcessors( + PhpPropertyNode $phpPropertyNode, + ?TypeNode $annotation, + TypeScriptProperty $property, + ): ?TypeScriptProperty { + $processors = $this->classPropertyProcessors; + + foreach ($processors as $processor) { + $property = $processor->execute($phpPropertyNode, $annotation, $property); + + if ($property === null) { + return null; + } + } + + return $property; + } +} diff --git a/src/Transformers/DtoTransformer.php b/src/Transformers/DtoTransformer.php deleted file mode 100644 index 512554a..0000000 --- a/src/Transformers/DtoTransformer.php +++ /dev/null @@ -1,121 +0,0 @@ -config = $config; - } - - public function transform(ReflectionClass $class, string $name): ?TransformedType - { - if (! $this->canTransform($class)) { - return null; - } - - $missingSymbols = new MissingSymbolsCollection(); - - $type = join([ - $this->transformProperties($class, $missingSymbols), - $this->transformMethods($class, $missingSymbols), - $this->transformExtra($class, $missingSymbols), - ]); - - return TransformedType::create( - $class, - $name, - "{" . PHP_EOL . $type . "}", - $missingSymbols - ); - } - - protected function canTransform(ReflectionClass $class): bool - { - return true; - } - - protected function transformProperties( - ReflectionClass $class, - MissingSymbolsCollection $missingSymbols - ): string { - $isOptional = ! empty($class->getAttributes(Optional::class)); - - return array_reduce( - $this->resolveProperties($class), - function (string $carry, ReflectionProperty $property) use ($isOptional, $missingSymbols) { - $isHidden = ! empty($property->getAttributes(Hidden::class)); - - if ($isHidden) { - return $carry; - } - - $isOptional = $isOptional || ! empty($property->getAttributes(Optional::class)); - - $transformed = $this->reflectionToTypeScript( - $property, - $missingSymbols, - ...$this->typeProcessors() - ); - - if ($transformed === null) { - return $carry; - } - - return $isOptional - ? "{$carry}{$property->getName()}?: {$transformed};" . PHP_EOL - : "{$carry}{$property->getName()}: {$transformed};" . PHP_EOL; - }, - '' - ); - } - - protected function transformMethods( - ReflectionClass $class, - MissingSymbolsCollection $missingSymbols - ): string { - return ''; - } - - protected function transformExtra( - ReflectionClass $class, - MissingSymbolsCollection $missingSymbols - ): string { - return ''; - } - - protected function typeProcessors(): array - { - return [ - new ReplaceDefaultsTypeProcessor( - $this->config->getDefaultTypeReplacements() - ), - new DtoCollectionTypeProcessor(), - ]; - } - - protected function resolveProperties(ReflectionClass $class): array - { - $properties = array_filter( - $class->getProperties(ReflectionProperty::IS_PUBLIC), - fn (ReflectionProperty $property) => ! $property->isStatic() - ); - - return array_values($properties); - } -} diff --git a/src/Transformers/EnumProviders/EnumProvider.php b/src/Transformers/EnumProviders/EnumProvider.php new file mode 100644 index 0000000..1fe5cab --- /dev/null +++ b/src/Transformers/EnumProviders/EnumProvider.php @@ -0,0 +1,14 @@ +isEnum(); + } + + public function isValidUnion(PhpClassNode $phpClassNode): bool + { + return $phpClassNode instanceof PhpEnumNode && $phpClassNode->isBacked(); + } + + /** + * @return array + */ + public function resolveCases(PhpClassNode|PhpEnumNode $phpClassNode): array + { + return array_map( + fn (PhpEnumCaseNode $case) => [ + 'name' => $case->getName(), + 'value' => $case->getValue(), + ], + array_values($phpClassNode->getCases()) + ); + } +} diff --git a/src/Transformers/EnumTransformer.php b/src/Transformers/EnumTransformer.php index 8f7a5a4..be795a3 100644 --- a/src/Transformers/EnumTransformer.php +++ b/src/Transformers/EnumTransformer.php @@ -2,73 +2,75 @@ namespace Spatie\TypeScriptTransformer\Transformers; -use ReflectionClass; -use ReflectionEnum; -use ReflectionEnumBackedCase; -use Spatie\TypeScriptTransformer\Structures\TransformedType; -use Spatie\TypeScriptTransformer\TypeScriptTransformerConfig; +use Spatie\TypeScriptTransformer\PhpNodes\PhpClassNode; +use Spatie\TypeScriptTransformer\References\PhpClassReference; +use Spatie\TypeScriptTransformer\Support\TransformationContext; +use Spatie\TypeScriptTransformer\Transformed\Transformed; +use Spatie\TypeScriptTransformer\Transformed\Untransformable; +use Spatie\TypeScriptTransformer\Transformers\EnumProviders\EnumProvider; +use Spatie\TypeScriptTransformer\Transformers\EnumProviders\PhpEnumProvider; +use Spatie\TypeScriptTransformer\TypeScriptNodes\TypeScriptAlias; +use Spatie\TypeScriptTransformer\TypeScriptNodes\TypeScriptEnum; +use Spatie\TypeScriptTransformer\TypeScriptNodes\TypeScriptIdentifier; +use Spatie\TypeScriptTransformer\TypeScriptNodes\TypeScriptLiteral; +use Spatie\TypeScriptTransformer\TypeScriptNodes\TypeScriptNode; +use Spatie\TypeScriptTransformer\TypeScriptNodes\TypeScriptUnion; class EnumTransformer implements Transformer { - public function __construct(protected TypeScriptTransformerConfig $config) - { + public function __construct( + public bool $useUnionEnums = true, + public EnumProvider $enumProvider = new PhpEnumProvider() + ) { } - public function transform(ReflectionClass $class, string $name): ?TransformedType - { - // If we're not on PHP >= 8.1, we don't support native enums. - if (! method_exists($class, 'isEnum')) { - return null; + public function transform( + PhpClassNode $phpClassNode, + TransformationContext $context + ): Transformed|Untransformable { + if (! $this->enumProvider->isEnum($phpClassNode)) { + return Untransformable::create(); } - if (! $class->isEnum()) { - return null; + if ($this->useUnionEnums === true && ! $this->enumProvider->isValidUnion($phpClassNode)) { + return Untransformable::create(); } - $enum = (new ReflectionEnum($class->getName())); + $cases = $this->enumProvider->resolveCases($phpClassNode); - if (! $enum->isBacked()) { - return null; + if (count($cases) === 0) { + return Untransformable::create(); } - return $this->config->shouldTransformToNativeEnums() - ? $this->toEnum($enum, $name) - : $this->toType($enum, $name); - } - - protected function toEnum(ReflectionEnum $enum, string $name): TransformedType - { - $options = array_map( - fn (ReflectionEnumBackedCase $case) => "'{$case->getName()}' = {$this->toEnumValue($case)}", - $enum->getCases() - ); - - return TransformedType::create( - $enum, - $name, - implode(', ', $options), - keyword: 'enum' + return new Transformed( + $this->useUnionEnums + ? $this->transformAsUnion($context->name, $cases) + : $this->transformAsNativeEnum($context->name, $cases), + new PhpClassReference($phpClassNode), + $context->nameSpaceSegments, + true, ); } - protected function toType(ReflectionEnum $enum, string $name): TransformedType - { - $options = array_map( - fn (ReflectionEnumBackedCase $case) => $this->toEnumValue($case), - $enum->getCases(), - ); - - return TransformedType::create( - $enum, - $name, - implode(' | ', $options) - ); + protected function transformAsNativeEnum( + string $name, + array $cases + ): TypeScriptNode { + return new TypeScriptEnum($name, $cases); } - protected function toEnumValue(ReflectionEnumBackedCase $case): string - { - $value = $case->getBackingValue(); - - return is_string($value) ? "'{$value}'" : "{$value}"; + protected function transformAsUnion( + string $name, + array $cases + ): TypeScriptNode { + return new TypeScriptAlias( + new TypeScriptIdentifier($name), + new TypeScriptUnion( + array_map( + fn (array $case) => new TypeScriptLiteral($case['value']), + $cases, + ), + ), + ); } } diff --git a/src/Transformers/InterfaceTransformer.php b/src/Transformers/InterfaceTransformer.php index 9e2303d..6f5a683 100644 --- a/src/Transformers/InterfaceTransformer.php +++ b/src/Transformers/InterfaceTransformer.php @@ -2,71 +2,152 @@ namespace Spatie\TypeScriptTransformer\Transformers; -use ReflectionClass; -use ReflectionMethod; -use Spatie\TypeScriptTransformer\Structures\MissingSymbolsCollection; -use Spatie\TypeScriptTransformer\Structures\TransformedType; +use Spatie\TypeScriptTransformer\Actions\TranspilePhpStanTypeToTypeScriptNodeAction; +use Spatie\TypeScriptTransformer\Actions\TranspilePhpTypeNodeToTypeScriptNodeAction; +use Spatie\TypeScriptTransformer\PhpNodes\PhpClassNode; +use Spatie\TypeScriptTransformer\PhpNodes\PhpMethodNode; +use Spatie\TypeScriptTransformer\PhpNodes\PhpParameterNode; +use Spatie\TypeScriptTransformer\References\PhpClassReference; +use Spatie\TypeScriptTransformer\Support\TransformationContext; +use Spatie\TypeScriptTransformer\Transformed\Transformed; +use Spatie\TypeScriptTransformer\Transformed\Untransformable; +use Spatie\TypeScriptTransformer\TypeResolvers\Data\ParsedMethod; +use Spatie\TypeScriptTransformer\TypeResolvers\Data\ParsedNameAndType; +use Spatie\TypeScriptTransformer\TypeResolvers\DocTypeResolver; +use Spatie\TypeScriptTransformer\TypeScriptNodes\TypeScriptIdentifier; +use Spatie\TypeScriptTransformer\TypeScriptNodes\TypeScriptInterface; +use Spatie\TypeScriptTransformer\TypeScriptNodes\TypeScriptInterfaceMethod; +use Spatie\TypeScriptTransformer\TypeScriptNodes\TypeScriptNode; +use Spatie\TypeScriptTransformer\TypeScriptNodes\TypeScriptParameter; +use Spatie\TypeScriptTransformer\TypeScriptNodes\TypeScriptProperty; +use Spatie\TypeScriptTransformer\TypeScriptNodes\TypeScriptUnknown; +use Spatie\TypeScriptTransformer\TypeScriptNodes\TypeScriptVoid; -class InterfaceTransformer extends DtoTransformer implements Transformer +abstract class InterfaceTransformer implements Transformer { - public function transform(ReflectionClass $class, string $name): ?TransformedType + public function __construct( + protected DocTypeResolver $docTypeResolver = new DocTypeResolver(), + protected TranspilePhpStanTypeToTypeScriptNodeAction $transpilePhpStanTypeToTypeScriptTypeAction = new TranspilePhpStanTypeToTypeScriptNodeAction(), + protected TranspilePhpTypeNodeToTypeScriptNodeAction $transpilePhpTypeNodeToTypeScriptNodeAction = new TranspilePhpTypeNodeToTypeScriptNodeAction(), + ) { + } + + public function transform(PhpClassNode $phpClassNode, TransformationContext $context): Transformed|Untransformable { - if (! $class->isInterface()) { - return null; + if (! $phpClassNode->isInterface()) { + return Untransformable::create(); + } + + if (! $this->shouldTransform($phpClassNode)) { + return Untransformable::create(); + } + + $node = new TypeScriptInterface( + new TypeScriptIdentifier($context->name), + $this->getProperties($phpClassNode, $context), + $this->getMethods($phpClassNode, $context) + ); + + return new Transformed( + $node, + new PhpClassReference($phpClassNode), + $context->nameSpaceSegments, + true, + ); + } + + abstract protected function shouldTransform(PhpClassNode $phpClassNode): bool; + + /** @return TypeScriptInterfaceMethod[] */ + protected function getMethods( + PhpClassNode $phpClassNode, + TransformationContext $context, + ): array { + $methods = []; + + foreach ($phpClassNode->getMethods() as $phpMethodNode) { + $methods[] = $this->getTypeScriptMethod($phpClassNode, $phpMethodNode, $context); } - $transformedType = parent::transform($class, $name); - $transformedType->keyword = 'interface'; - $transformedType->trailingSemicolon = false; + return $methods; + } - return $transformedType; + /** @return TypeScriptProperty[] */ + protected function getProperties( + PhpClassNode $phpClassNode, + TransformationContext $context, + ): array { + return []; } - protected function transformMethods( - ReflectionClass $class, - MissingSymbolsCollection $missingSymbols - ): string { - return array_reduce( - $class->getMethods(ReflectionMethod::IS_PUBLIC), - function (string $carry, ReflectionMethod $method) use ($missingSymbols) { - $transformedParameters = \array_reduce( - $method->getParameters(), - function (string $parameterCarry, \ReflectionParameter $parameter) use ($missingSymbols) { - $type = $this->reflectionToTypeScript( - $parameter, - $missingSymbols, - ...$this->typeProcessors() - ); - - $output = ''; - if ($parameterCarry !== '') { - $output .= ', '; - } - - return "{$parameterCarry}{$output}{$parameter->getName()}: {$type}"; - }, - '' - ); - - $returnType = 'any'; - if ($method->hasReturnType()) { - $returnType = $this->reflectionToTypeScript( - $method, - $missingSymbols, - ...$this->typeProcessors() - ); - } - - return "{$carry}{$method->getName()}({$transformedParameters}): {$returnType};" . PHP_EOL; - }, - '' + protected function getTypeScriptMethod( + PhpClassNode $phpClassNode, + PhpMethodNode $phpMethodNode, + TransformationContext $context, + ): TypeScriptInterfaceMethod { + $annotation = $this->docTypeResolver->method($phpMethodNode); + + return new TypeScriptInterfaceMethod( + $phpMethodNode->getName(), + array_map(fn (PhpParameterNode $parameterNode) => $this->resolveMethodParameterType( + $phpClassNode, + $phpMethodNode, + $parameterNode, + $context, + $annotation->parameters[$parameterNode->getName()] ?? null + ), $phpMethodNode->getParameters()), + $this->resolveMethodReturnType($phpClassNode, $phpMethodNode, $context, $annotation) ); } - protected function transformProperties( - ReflectionClass $class, - MissingSymbolsCollection $missingSymbols - ): string { - return ''; + protected function resolveMethodReturnType( + PhpClassNode $classNode, + PhpMethodNode $methodNode, + TransformationContext $context, + ?ParsedMethod $annotation + ): TypeScriptNode { + if ($annotation->returnType) { + return $this->transpilePhpStanTypeToTypeScriptTypeAction->execute( + $annotation->returnType, + $classNode + ); + } + + $returnType = $methodNode->getReturnType(); + + if ($returnType) { + return $this->transpilePhpTypeNodeToTypeScriptNodeAction->execute( + $returnType, + $classNode + ); + } + + return new TypeScriptVoid(); + } + + protected function resolveMethodParameterType( + PhpClassNode $classNode, + PhpMethodNode $methodNode, + PhpParameterNode $parameterNode, + TransformationContext $context, + ?ParsedNameAndType $annotation, + ): TypeScriptParameter { + $type = match (true) { + $annotation !== null => $this->transpilePhpStanTypeToTypeScriptTypeAction->execute( + $annotation->type, + $classNode + ), + $parameterNode->hasType() => $this->transpilePhpTypeNodeToTypeScriptNodeAction->execute( + $parameterNode->getType(), + $classNode + ), + default => new TypeScriptUnknown(), + }; + + return new TypeScriptParameter( + $parameterNode->getName(), + $type, + $parameterNode->isOptional() + ); } } diff --git a/src/Transformers/MyclabsEnumTransformer.php b/src/Transformers/MyclabsEnumTransformer.php deleted file mode 100644 index cab53b6..0000000 --- a/src/Transformers/MyclabsEnumTransformer.php +++ /dev/null @@ -1,62 +0,0 @@ -isSubclassOf(Enum::class) === false) { - return null; - } - - return $this->config->shouldTransformToNativeEnums() - ? $this->toEnum($class, $name) - : $this->toType($class, $name); - } - - protected function toEnum(ReflectionClass $class, string $name): TransformedType - { - /** @var \MyCLabs\Enum\Enum $enum */ - $enum = $class->getName(); - - $options = array_map( - fn ($key, $value) => "'{$key}' = '{$value}'", - array_keys($enum::toArray()), - $enum::toArray() - ); - - return TransformedType::create( - $class, - $name, - implode(', ', $options), - keyword: 'enum' - ); - } - - protected function toType(ReflectionClass $class, string $name): TransformedType - { - /** @var \MyCLabs\Enum\Enum $enum */ - $enum = $class->getName(); - - $options = array_map( - fn (Enum $enum) => "'{$enum->getValue()}'", - $enum::values() - ); - - return TransformedType::create( - $class, - $name, - implode(' | ', $options) - ); - } -} diff --git a/src/Transformers/SpatieEnumTransformer.php b/src/Transformers/SpatieEnumTransformer.php deleted file mode 100644 index 5824c67..0000000 --- a/src/Transformers/SpatieEnumTransformer.php +++ /dev/null @@ -1,62 +0,0 @@ -isSubclassOf(Enum::class) === false) { - return null; - } - - return $this->config->shouldTransformToNativeEnums() - ? $this->toEnum($class, $name) - : $this->toType($class, $name); - } - - protected function toEnum(ReflectionClass $class, string $name): TransformedType - { - /** @var \Spatie\Enum\Enum $enum */ - $enum = $class->getName(); - - $options = array_map( - fn ($key, $value) => "'{$key}' = '{$value}'", - array_keys($enum::toArray()), - $enum::toArray() - ); - - return TransformedType::create( - $class, - $name, - implode(', ', $options), - keyword: 'enum' - ); - } - - private function toType(ReflectionClass $class, string $name): TransformedType - { - /** @var \Spatie\Enum\Enum $enum */ - $enum = $class->getName(); - - $options = array_map( - fn ($enum) => "'{$enum}'", - array_keys($enum::toArray()) - ); - - return TransformedType::create( - $class, - $name, - implode(' | ', $options) - ); - } -} diff --git a/src/Transformers/Transformer.php b/src/Transformers/Transformer.php index 0a83479..041cb59 100644 --- a/src/Transformers/Transformer.php +++ b/src/Transformers/Transformer.php @@ -2,10 +2,12 @@ namespace Spatie\TypeScriptTransformer\Transformers; -use ReflectionClass; -use Spatie\TypeScriptTransformer\Structures\TransformedType; +use Spatie\TypeScriptTransformer\PhpNodes\PhpClassNode; +use Spatie\TypeScriptTransformer\Support\TransformationContext; +use Spatie\TypeScriptTransformer\Transformed\Transformed; +use Spatie\TypeScriptTransformer\Transformed\Untransformable; interface Transformer { - public function transform(ReflectionClass $class, string $name): ?TransformedType; + public function transform(PhpClassNode $phpClassNode, TransformationContext $context): Transformed|Untransformable; } diff --git a/src/Transformers/TransformsTypes.php b/src/Transformers/TransformsTypes.php deleted file mode 100644 index 6863dae..0000000 --- a/src/Transformers/TransformsTypes.php +++ /dev/null @@ -1,72 +0,0 @@ -reflectionToType( - $reflection, - $missingSymbolsCollection, - ...$typeProcessors - ); - - if ($type === null) { - return null; - } - - return $this->typeToTypeScript( - $type, - $missingSymbolsCollection, - $reflection->getDeclaringClass()?->getName() - ); - } - - protected function reflectionToType( - ReflectionMethod | ReflectionProperty | ReflectionParameter $reflection, - MissingSymbolsCollection $missingSymbolsCollection, - TypeProcessor ...$typeProcessors - ): ?Type { - $type = TypeReflector::new($reflection)->reflect(); - - foreach ($typeProcessors as $processor) { - $type = $processor->process( - $type, - $reflection, - $missingSymbolsCollection - ); - - if ($type === null) { - return null; - } - } - - return $type; - } - - protected function typeToTypeScript( - Type $type, - MissingSymbolsCollection $missingSymbolsCollection, - ?string $currentClass = null, - ): string { - $transpiler = new TranspileTypeToTypeScriptAction( - $missingSymbolsCollection, - $currentClass, - ); - - return $transpiler->execute($type); - } -} diff --git a/src/TypeProcessors/DtoCollectionTypeProcessor.php b/src/TypeProcessors/DtoCollectionTypeProcessor.php deleted file mode 100644 index e70e5d2..0000000 --- a/src/TypeProcessors/DtoCollectionTypeProcessor.php +++ /dev/null @@ -1,43 +0,0 @@ -walk($type, function (Type $type) { - if (! $type instanceof Object_) { - return $type; - } - - $fqs = ltrim((string) $type->getFqsen(), '\\'); - - if (! is_subclass_of($fqs, DataTransferObjectCollection::class)) { - return $type; - } - - $reflection = new ReflectionClass($fqs); - - return new Array_( - TypeReflector::new($reflection->getMethod('current'))->reflect() - ); - }); - } -} diff --git a/src/TypeProcessors/ProcessesTypes.php b/src/TypeProcessors/ProcessesTypes.php deleted file mode 100644 index b90cf60..0000000 --- a/src/TypeProcessors/ProcessesTypes.php +++ /dev/null @@ -1,82 +0,0 @@ - $this->walk($type, $closure), - iterator_to_array($type->getIterator()) - ); - - $walkedTypes = array_filter($walkedTypes); - - if (empty($walkedTypes)) { - return null; - } - - if (count($walkedTypes) === 1) { - return current($walkedTypes); - } - - return $closure(new Compound($walkedTypes)); - } - - if ($type instanceof AbstractList) { - $walkedValueType = $this->walk($type->getValueType(), $closure); - $walkedKeyType = $this->walk($type->getKeyType(), $closure); - - if ($walkedValueType === null || $walkedKeyType === null) { - return null; - } - - return $closure( - $this->updateListType($type, $walkedValueType, $walkedKeyType) - ); - } - - if ($type instanceof Nullable) { - $walkedType = $this->walk($type->getActualType(), $closure); - - if ($walkedType === null) { - return null; - } - - return $closure(new Nullable($walkedType)); - } - - return $closure($type); - } - - protected function updateListType( - AbstractList $type, - Type $valueType, - ?Type $keyType = null - ): Type { - $keyType = $type->getKeyType(); - - if ((string) $keyType === (string) new Compound([new String_(), new Integer()])) { - $keyType = null; - } - - if ($type instanceof Collection) { - return new Collection($type->getFqsen(), $valueType, $keyType); - } - - $typeClass = get_class($type); - - return new $typeClass($valueType, $keyType); - } -} diff --git a/src/TypeProcessors/ReplaceDefaultsTypeProcessor.php b/src/TypeProcessors/ReplaceDefaultsTypeProcessor.php deleted file mode 100644 index 9407121..0000000 --- a/src/TypeProcessors/ReplaceDefaultsTypeProcessor.php +++ /dev/null @@ -1,43 +0,0 @@ - */ - private array $mapping; - - public function __construct(array $mapping) - { - $this->mapping = $mapping; - } - - public function process( - Type $type, - ReflectionProperty | ReflectionParameter | ReflectionMethod $reflection, - MissingSymbolsCollection $missingSymbolsCollection - ): ?Type { - return $this->walk($type, function (Type $type) { - if (! $type instanceof Object_) { - return $type; - } - - foreach ($this->mapping as $replacementClass => $replacementType) { - if (ltrim((string) $type->getFqsen(), '\\') === $replacementClass) { - return $replacementType; - } - } - - return $type; - }); - } -} diff --git a/src/TypeProcessors/TypeProcessor.php b/src/TypeProcessors/TypeProcessor.php deleted file mode 100644 index 4140879..0000000 --- a/src/TypeProcessors/TypeProcessor.php +++ /dev/null @@ -1,18 +0,0 @@ - $transformers + * @param array $directories + */ + public function __construct( + protected array $transformers, + protected array $directories, + ) { + } + + public function provide( + TypeScriptTransformerConfig $config, + TransformedCollection $types + ): void { + $transformTypesAction = new TransformTypesAction(); + $discoverTypesAction = new DiscoverTypesAction(); + + $types->add(...$transformTypesAction->execute( + $this->transformers, + $discoverTypesAction->execute($this->directories), + )); + } +} diff --git a/src/TypeProviders/TypesProvider.php b/src/TypeProviders/TypesProvider.php new file mode 100644 index 0000000..97edf1d --- /dev/null +++ b/src/TypeProviders/TypesProvider.php @@ -0,0 +1,14 @@ +reflect(); - } - - public function isTransformable(): bool - { - return $this->transformable; - } - - public function getType(): ?Type - { - return $this->type; - } - - public function getName(): ?string - { - return $this->name; - } - - public function getTransformerClass(): ?string - { - return $this->transformerClass; - } - - public function isInline(): bool - { - return $this->inline; - } - - public function getReflectionClass(): ReflectionClass - { - return $this->class; - } - - private function reflect(): void - { - [ - 'transformable' => $this->transformable, - 'name' => $this->name, - 'transformer' => $this->transformerClass, - 'inline' => $this->inline, - ] = (new ClassReader())->forClass($this->class); - - $attributes = $this->class->getAttributes(); - - $this->reflectName($attributes) - ->reflectInline($attributes) - ->reflectType($attributes) - ->reflectTransformer($attributes); - } - - private function reflectName(array $attributes): self - { - $nameAttributes = array_values(array_filter( - $attributes, - fn (ReflectionAttribute $attribute) => is_a($attribute->getName(), TypeScript::class, true) - )); - - if (! empty($nameAttributes)) { - /** @var \Spatie\TypeScriptTransformer\Attributes\TypeScript $nameAttribute */ - $nameAttribute = $nameAttributes[0]->newInstance(); - - $this->transformable = true; - $this->name = $nameAttribute->name ?? $this->name; - } - - return $this; - } - - private function reflectInline(array $attributes): self - { - $inlineAttributes = array_values(array_filter( - $attributes, - fn (ReflectionAttribute $attribute) => is_a($attribute->getName(), InlineTypeScriptType::class, true) - )); - - if (! empty($inlineAttributes)) { - $this->transformable = true; - $this->inline = true; - } - - return $this; - } - - private function reflectType(array $attributes): self - { - $transformableAttributes = array_values(array_filter( - $attributes, - fn (ReflectionAttribute $attribute) => is_a($attribute->getName(), TypeScriptTransformableAttribute::class, true) - )); - - if (! empty($transformableAttributes)) { - /** @var \Spatie\TypeScriptTransformer\Attributes\TypeScriptTransformableAttribute $transformableAttribute */ - $transformableAttribute = $transformableAttributes[0]->newInstance(); - - $this->transformable = true; - $this->type = $transformableAttribute->getType(); - } - - return $this; - } - - private function reflectTransformer(array $attributes): self - { - if ($this->type) { - return $this; - } - - $transformerAttributes = array_values(array_filter( - $attributes, - fn (ReflectionAttribute $attribute) => is_a($attribute->getName(), TypeScriptTransformer::class, true) - )); - - if (! empty($transformerAttributes)) { - /** @var \Spatie\TypeScriptTransformer\Attributes\TypeScriptTransformer $transformerAttribute */ - $transformerAttribute = $transformerAttributes[0]->newInstance(); - - $this->transformable = true; - $this->transformerClass = $transformerAttribute->transformer; - } - - return $this; - } -} diff --git a/src/TypeReflectors/MethodParameterTypeReflector.php b/src/TypeReflectors/MethodParameterTypeReflector.php deleted file mode 100644 index 458143c..0000000 --- a/src/TypeReflectors/MethodParameterTypeReflector.php +++ /dev/null @@ -1,39 +0,0 @@ -reflection->getDeclaringFunction()->getDocComment(); - } - - protected function docblockRegex(): string - { - return "/@param ((?:\\s?[\\w?|\\\\<>,-]+(?:\\[])?)+) \\\${$this->reflection->getName()}/"; - } - - protected function getReflectionType(): ?ReflectionType - { - return $this->reflection->getType(); - } - - protected function getAttributes(): array - { - return []; - } -} diff --git a/src/TypeReflectors/MethodReturnTypeReflector.php b/src/TypeReflectors/MethodReturnTypeReflector.php deleted file mode 100644 index e63c4f9..0000000 --- a/src/TypeReflectors/MethodReturnTypeReflector.php +++ /dev/null @@ -1,39 +0,0 @@ -reflection->getDocComment(); - } - - protected function docblockRegex(): string - { - return '/@return ((?:\s?[\w?|\\\\<>,-]+(?:\[])?)+)/'; - } - - protected function getReflectionType(): ?ReflectionType - { - return $this->reflection->getReturnType(); - } - - protected function getAttributes(): array - { - return $this->reflection->getAttributes(); - } -} diff --git a/src/TypeReflectors/PropertyTypeReflector.php b/src/TypeReflectors/PropertyTypeReflector.php deleted file mode 100644 index be7a16d..0000000 --- a/src/TypeReflectors/PropertyTypeReflector.php +++ /dev/null @@ -1,39 +0,0 @@ -reflection->getDocComment(); - } - - protected function docblockRegex(): string - { - return '/@var ((?:\s?[\\w?|\\\\<>,-]+(?:\[])?)+)/'; - } - - protected function getReflectionType(): ?ReflectionType - { - return $this->reflection->getType(); - } - - protected function getAttributes(): array - { - return $this->reflection->getAttributes(); - } -} diff --git a/src/TypeReflectors/TypeReflector.php b/src/TypeReflectors/TypeReflector.php deleted file mode 100644 index 7f16e80..0000000 --- a/src/TypeReflectors/TypeReflector.php +++ /dev/null @@ -1,165 +0,0 @@ -reflectionFromAttribute()) { - return $type; - } - - if ($type = $this->reflectFromDocblock()) { - return $type; - } - - if ($type = $this->reflectFromReflection()) { - return $type; - } - - return new TypeScriptType('any'); - } - - public function reflectionFromAttribute(): ?Type - { - $attributes = array_filter( - $this->getAttributes(), - fn (ReflectionAttribute $attribute) => is_a($attribute->getName(), TypeScriptTransformableAttribute::class, true) - ); - - if (empty($attributes)) { - return null; - } - - /** @var \Spatie\TypeScriptTransformer\Attributes\TypeScriptTransformableAttribute $attribute */ - $attribute = current($attributes)->newInstance(); - - return $attribute->getType(); - } - - public function reflectFromDocblock(): ?Type - { - preg_match( - $this->docblockRegex(), - $this->getDocblock(), - $matches - ); - - $docDefinition = $matches[1] ?? null; - - if ($docDefinition === null) { - return null; - } - - $type = (new TypeResolver())->resolve( - $docDefinition, - (new ContextFactory())->createFromReflector($this->reflection) - ); - - return $this->nullifyType($type); - } - - public function reflectFromReflection(): ?Type - { - $reflectionType = $this->getReflectionType(); - - if ($reflectionType === null) { - return null; - } - - if ($reflectionType instanceof ReflectionUnionType) { - $type = new Compound(array_map( - fn (ReflectionNamedType $reflectionType) => (new TypeResolver())->resolve( - $reflectionType->getName(), - ), - $reflectionType->getTypes() - )); - - return $this->nullifyType($type); - } - - if (! $reflectionType instanceof ReflectionNamedType) { - return null; - } - - $type = (new TypeResolver())->resolve( - $reflectionType->getName(), - ); - - return $this->nullifyType($type); - } - - private function nullifyType(Type $type): Type - { - $reflectionType = $this->getReflectionType(); - - if ($reflectionType === null || $reflectionType->allowsNull() === false) { - return $type; - } - - if ($type instanceof Mixed_) { - return $type; - } - - if ($type instanceof Nullable) { - return $type; - } - - if ($type instanceof Compound && $type->contains(new Null_())) { - return $type; - } - - if ($type instanceof Compound) { - /** @psalm-suppress InvalidArgument */ - return new Compound(array_merge( - iterator_to_array($type->getIterator()), - [new Null_()], - )); - } - - return new Nullable($type); - } -} diff --git a/src/TypeResolvers/Data/ParsedClass.php b/src/TypeResolvers/Data/ParsedClass.php new file mode 100644 index 0000000..696df3d --- /dev/null +++ b/src/TypeResolvers/Data/ParsedClass.php @@ -0,0 +1,14 @@ + $properties + */ + public function __construct( + public array $properties, + ) { + } +} diff --git a/src/TypeResolvers/Data/ParsedMethod.php b/src/TypeResolvers/Data/ParsedMethod.php new file mode 100644 index 0000000..f321f1e --- /dev/null +++ b/src/TypeResolvers/Data/ParsedMethod.php @@ -0,0 +1,17 @@ + $parameters + */ + public function __construct( + public array $parameters, + public ?TypeNode $returnType, + ) { + } +} diff --git a/src/TypeResolvers/Data/ParsedNameAndType.php b/src/TypeResolvers/Data/ParsedNameAndType.php new file mode 100644 index 0000000..265aefd --- /dev/null +++ b/src/TypeResolvers/Data/ParsedNameAndType.php @@ -0,0 +1,14 @@ +typeParser = new TypeParser($constExprParser); + + $this->docParser = new PhpDocParser($this->typeParser, $constExprParser); + $this->lexer = new Lexer(); + } + + public function class(PhpClassNode $phpClassNode): ?ParsedClass + { + $parsed = $this->parseDocComment($phpClassNode); + + if ($parsed === null) { + return null; + } + + $properties = []; + + foreach ($parsed->getPropertyTagValues() as $propertyTag) { + $name = ltrim($propertyTag->propertyName, '$'); + + $properties[$name] = new ParsedNameAndType($name, $propertyTag->type); + } + + if (empty($properties)) { + return null; + } + + return new ParsedClass($properties); + } + + public function method(PhpMethodNode $phpMethodNode): ?ParsedMethod + { + $parsed = $this->parseDocComment($phpMethodNode); + + if ($parsed === null) { + return null; + } + + $parameters = []; + + foreach ($parsed->getParamTagValues() as $paramTag) { + $name = ltrim($paramTag->parameterName, '$'); + + $parameters[$name] = new ParsedNameAndType($name, $paramTag->type); + } + + $return = null; + + foreach ($parsed->getReturnTagValues() as $returnTag) { + $return = $returnTag->type; + } + + if (empty($parameters) && $return === null) { + return null; + } + + return new ParsedMethod($parameters, $return); + } + + public function property(PhpPropertyNode $phpPropertyNode): ?ParsedNameAndType + { + $parsed = $this->parseDocComment($phpPropertyNode); + + if ($parsed === null) { + return null; + } + + $var = null; + + foreach ($parsed->getVarTagValues() as $varTag) { + $var = $varTag->type; + } + + if ($var === null) { + return null; + } + + return new ParsedNameAndType($phpPropertyNode->getName(), $var); + } + + public function type(string $type): TypeNode + { + return $this->typeParser->parse( + new TokenIterator($this->lexer->tokenize($type)) + ); + } + + protected function parseDocComment( + PhpClassNode|PhpMethodNode|PhpPropertyNode $phpNode + ): ?PhpDocNode { + if ($phpNode->getDocComment() === false || $phpNode->getDocComment() === null) { + return null; + } + + return $this->docParser->parse( + new TokenIterator($this->lexer->tokenize($phpNode->getDocComment())) + ); + } +} diff --git a/src/TypeScriptNodes/TypeReference.php b/src/TypeScriptNodes/TypeReference.php new file mode 100644 index 0000000..4ac726e --- /dev/null +++ b/src/TypeScriptNodes/TypeReference.php @@ -0,0 +1,46 @@ +referenced = $transformed; + } + + public function unconnect(): void + { + $this->referenced = null; + } + + public function write(WritingContext $context): string + { + if ($this->referenced === null) { + return 'undefined'; + } + + return ($context->referenceWriter)($this->reference); + } + + public function getName(): string + { + return $this->referenced->getName(); + } +} diff --git a/src/TypeScriptNodes/TypeScriptAlias.php b/src/TypeScriptNodes/TypeScriptAlias.php new file mode 100644 index 0000000..79e71fc --- /dev/null +++ b/src/TypeScriptNodes/TypeScriptAlias.php @@ -0,0 +1,30 @@ +identifier->write($context)} = {$this->type->write($context)};"; + } + + public function visitorProfile(): VisitorProfile + { + return VisitorProfile::create()->single('identifier', 'type'); + } + + public function getForwardedNamedNode(): TypeScriptNamedNode|TypeScriptForwardingNamedNode + { + return $this->identifier; + } +} diff --git a/src/TypeScriptNodes/TypeScriptAny.php b/src/TypeScriptNodes/TypeScriptAny.php new file mode 100644 index 0000000..27bc001 --- /dev/null +++ b/src/TypeScriptNodes/TypeScriptAny.php @@ -0,0 +1,13 @@ + $type->write($context), + $this->types + )); + + return "[$types]"; + } + + public function visitorProfile(): VisitorProfile + { + return VisitorProfile::create()->iterable('types'); + } +} diff --git a/src/TypeScriptNodes/TypeScriptBoolean.php b/src/TypeScriptNodes/TypeScriptBoolean.php new file mode 100644 index 0000000..f97730b --- /dev/null +++ b/src/TypeScriptNodes/TypeScriptBoolean.php @@ -0,0 +1,13 @@ +condition->write($context)} ? {$this->ifTrue->write($context)} : {$this->ifFalse->write($context)}"; + } + + public function visitorProfile(): VisitorProfile + { + return VisitorProfile::create()->single('condition', 'ifTrue', 'ifFalse'); + } +} diff --git a/src/TypeScriptNodes/TypeScriptEnum.php b/src/TypeScriptNodes/TypeScriptEnum.php new file mode 100644 index 0000000..9b20902 --- /dev/null +++ b/src/TypeScriptNodes/TypeScriptEnum.php @@ -0,0 +1,44 @@ + $cases + */ + public function __construct( + public string $name, + public array $cases, + ) { + } + + public function write(WritingContext $context): string + { + $output = 'enum '.$this->name.' {'.PHP_EOL; + + foreach ($this->cases as $case) { + $output .= ' '; + + $output .= match (true) { + is_int($case['value']) => "{$case['name']} = {$case['value']},", + is_string($case['value']) => "{$case['name']} = '{$case['value']}',", + default => "{$case['name']},", + }; + + $output .= PHP_EOL; + } + + $output .= '}'; + + return $output; + } + + public function getName(): string + { + return $this->name; + } +} diff --git a/src/TypeScriptNodes/TypeScriptExport.php b/src/TypeScriptNodes/TypeScriptExport.php new file mode 100644 index 0000000..3f74cc9 --- /dev/null +++ b/src/TypeScriptNodes/TypeScriptExport.php @@ -0,0 +1,24 @@ +node->write($context)}"; + } + + public function visitorProfile(): VisitorProfile + { + return VisitorProfile::create()->single('node'); + } +} diff --git a/src/TypeScriptNodes/TypeScriptForwardingNamedNode.php b/src/TypeScriptNodes/TypeScriptForwardingNamedNode.php new file mode 100644 index 0000000..843ef6d --- /dev/null +++ b/src/TypeScriptNodes/TypeScriptForwardingNamedNode.php @@ -0,0 +1,8 @@ + $parameter->write($context), $this->parameters)); + + return "function {$this->identifier->write($context)}({$parameters}): {$this->returnType->write($context)} { + {$this->body->write($context)} + }"; + } + + public function visitorProfile(): VisitorProfile + { + return VisitorProfile::create()->single('identifier', 'returnType', 'body')->iterable('parameters'); + } + + public function getForwardedNamedNode(): TypeScriptNamedNode|TypeScriptForwardingNamedNode + { + return $this->identifier; + } +} diff --git a/src/TypeScriptNodes/TypeScriptGeneric.php b/src/TypeScriptNodes/TypeScriptGeneric.php new file mode 100644 index 0000000..ddff3be --- /dev/null +++ b/src/TypeScriptNodes/TypeScriptGeneric.php @@ -0,0 +1,38 @@ + $genericTypes + */ + public function __construct( + public TypeScriptIdentifier|TypeReference $type, + public array $genericTypes, + ) { + } + + public function write(WritingContext $context): string + { + $generics = implode(', ', array_map( + fn (TypeScriptNode $type) => $type->write($context), + $this->genericTypes + )); + + return "{$this->type->write($context)}<{$generics}>"; + } + + public function visitorProfile(): VisitorProfile + { + return VisitorProfile::create()->single('type')->iterable('genericTypes'); + } + + public function getForwardedNamedNode(): TypeScriptNamedNode|TypeScriptForwardingNamedNode + { + return $this->type; + } +} diff --git a/src/TypeScriptNodes/TypeScriptGenericTypeVariable.php b/src/TypeScriptNodes/TypeScriptGenericTypeVariable.php new file mode 100644 index 0000000..70fb963 --- /dev/null +++ b/src/TypeScriptNodes/TypeScriptGenericTypeVariable.php @@ -0,0 +1,37 @@ +identifier->write($context)}". + ($this->extends ? " extends {$this->extends?->write($context)}" : ''). + ($this->default ? " = {$this->default?->write($context)}" : ''); + } + + public function children(): array + { + return array_filter([ + $this->identifier, + $this->extends, + $this->default, + ]); + } + + public function visitorProfile(): VisitorProfile + { + return VisitorProfile::create()->single('identifier', 'extends', 'default'); + } +} diff --git a/src/TypeScriptNodes/TypeScriptIdentifier.php b/src/TypeScriptNodes/TypeScriptIdentifier.php new file mode 100644 index 0000000..85d9d28 --- /dev/null +++ b/src/TypeScriptNodes/TypeScriptIdentifier.php @@ -0,0 +1,23 @@ +name, '.') || str_contains($this->name, '\\')) ? "'{$this->name}'" : $this->name; + } + + public function getName(): string + { + return $this->name; + } +} diff --git a/src/TypeScriptNodes/TypeScriptImport.php b/src/TypeScriptNodes/TypeScriptImport.php new file mode 100644 index 0000000..12bb67b --- /dev/null +++ b/src/TypeScriptNodes/TypeScriptImport.php @@ -0,0 +1,25 @@ + $names + */ + public function __construct( + public string $path, + public array $names, + ) { + } + + public function write(WritingContext $context): string + { + $names = implode(', ', $this->names); + + return "import { {$names} } from '{$this->path}';"; + } +} diff --git a/src/TypeScriptNodes/TypeScriptIndexSignature.php b/src/TypeScriptNodes/TypeScriptIndexSignature.php new file mode 100644 index 0000000..998cec3 --- /dev/null +++ b/src/TypeScriptNodes/TypeScriptIndexSignature.php @@ -0,0 +1,25 @@ +name}: {$this->type->write($context)}]]"; + } + + public function visitorProfile(): VisitorProfile + { + return VisitorProfile::create()->single('type'); + } +} diff --git a/src/TypeScriptNodes/TypeScriptIndexedAccess.php b/src/TypeScriptNodes/TypeScriptIndexedAccess.php new file mode 100644 index 0000000..4159f25 --- /dev/null +++ b/src/TypeScriptNodes/TypeScriptIndexedAccess.php @@ -0,0 +1,33 @@ + $segments + */ + public function __construct( + public TypeScriptIdentifier|TypeReference $node, + public array $segments, + ) { + } + + public function write(WritingContext $context): string + { + $segments = array_map( + fn (TypeScriptNode $segment) => "[{$segment->write($context)}]", + $this->segments + ); + + return "{$this->node->write($context)}".implode('', $segments); + } + + public function visitorProfile(): VisitorProfile + { + return VisitorProfile::create()->single('node')->iterable('segments'); + } +} diff --git a/src/TypeScriptNodes/TypeScriptInterface.php b/src/TypeScriptNodes/TypeScriptInterface.php new file mode 100644 index 0000000..40106c4 --- /dev/null +++ b/src/TypeScriptNodes/TypeScriptInterface.php @@ -0,0 +1,46 @@ + $properties + * @param array $methods + */ + public function __construct( + public TypeScriptIdentifier $name, + public array $properties, + public array $methods, + ) { + } + + public function write(WritingContext $context): string + { + $combined = [...$this->properties, ...$this->methods]; + + $items = array_reduce( + $combined, + fn (string $carry, TypeScriptProperty|TypeScriptInterfaceMethod $item) => $carry.$item->write($context).PHP_EOL, + empty($combined) ? '' : PHP_EOL + ); + + return "interface {$this->name->write($context)} {{$items}}"; + } + + public function visitorProfile(): VisitorProfile + { + return VisitorProfile::create() + ->single('name') + ->iterable('properties') + ->iterable('methods'); + } + + public function getForwardedNamedNode(): TypeScriptNamedNode|TypeScriptForwardingNamedNode + { + return $this->name; + } +} diff --git a/src/TypeScriptNodes/TypeScriptInterfaceMethod.php b/src/TypeScriptNodes/TypeScriptInterfaceMethod.php new file mode 100644 index 0000000..bec8721 --- /dev/null +++ b/src/TypeScriptNodes/TypeScriptInterfaceMethod.php @@ -0,0 +1,34 @@ + $parameters + */ + public function __construct( + public string $name, + public array $parameters, + public TypeScriptNode $returnType, + ) { + } + + public function write(WritingContext $context): string + { + $parameters = implode(', ', array_map( + fn (TypeScriptParameter $parameter) => $parameter->write($context), + $this->parameters + )); + + return "{$this->name}({$parameters}): {$this->returnType->write($context)};"; + } + + public function visitorProfile(): VisitorProfile + { + return VisitorProfile::create()->iterable('parameters')->single('returnType'); + } +} diff --git a/src/TypeScriptNodes/TypeScriptIntersection.php b/src/TypeScriptNodes/TypeScriptIntersection.php new file mode 100644 index 0000000..4acb336 --- /dev/null +++ b/src/TypeScriptNodes/TypeScriptIntersection.php @@ -0,0 +1,30 @@ + $types + */ + public function __construct( + public array $types, + ) { + } + + public function write(WritingContext $context): string + { + return implode(' & ', array_map( + fn (TypeScriptNode $type) => $type->write($context), + $this->types + )); + } + + public function visitorProfile(): VisitorProfile + { + return VisitorProfile::create()->iterable('types'); + } +} diff --git a/src/TypeScriptNodes/TypeScriptLiteral.php b/src/TypeScriptNodes/TypeScriptLiteral.php new file mode 100644 index 0000000..b5006b9 --- /dev/null +++ b/src/TypeScriptNodes/TypeScriptLiteral.php @@ -0,0 +1,17 @@ +value); + } +} diff --git a/src/TypeScriptNodes/TypeScriptNamedNode.php b/src/TypeScriptNodes/TypeScriptNamedNode.php new file mode 100644 index 0000000..1a9a37a --- /dev/null +++ b/src/TypeScriptNodes/TypeScriptNamedNode.php @@ -0,0 +1,8 @@ + $types + */ + public function __construct( + public array $namespaceSegments, + public array $types, + ) { + } + + public function write(WritingContext $context): string + { + $output = 'declare namespace '.implode('.', $this->namespaceSegments).'{'.PHP_EOL; + + foreach ($this->types as $type) { + $output .= $type->write($context).PHP_EOL; + } + + $output .= '}'; + + return $output; + } +} diff --git a/src/TypeScriptNodes/TypeScriptNode.php b/src/TypeScriptNodes/TypeScriptNode.php new file mode 100644 index 0000000..e4759ae --- /dev/null +++ b/src/TypeScriptNodes/TypeScriptNode.php @@ -0,0 +1,10 @@ + $properties + */ + public function __construct( + public array $properties + ) { + } + + public function write(WritingContext $context): string + { + if (empty($this->properties)) { + return 'object'; + } + + $output = '{'.PHP_EOL; + + foreach ($this->properties as $property) { + $output .= $property->write($context).PHP_EOL; + } + + return $output.'}'; + } + + public function visitorProfile(): VisitorProfile + { + return VisitorProfile::create()->iterable('properties'); + } +} diff --git a/src/TypeScriptNodes/TypeScriptOperator.php b/src/TypeScriptNodes/TypeScriptOperator.php new file mode 100644 index 0000000..ae47cdb --- /dev/null +++ b/src/TypeScriptNodes/TypeScriptOperator.php @@ -0,0 +1,69 @@ +left === null) { + return "{$this->operator} {$this->right->write($context)}"; + } + + return "{$this->left->write($context)} {$this->operator} {$this->right->write($context)}"; + } + + public function visitorProfile(): VisitorProfile + { + return VisitorProfile::create()->single('left', 'right'); + } +} diff --git a/src/TypeScriptNodes/TypeScriptParameter.php b/src/TypeScriptNodes/TypeScriptParameter.php new file mode 100644 index 0000000..7fc4536 --- /dev/null +++ b/src/TypeScriptNodes/TypeScriptParameter.php @@ -0,0 +1,32 @@ +name) + ? "'{$this->name}'" + : $this->name; + + return $this->isOptional + ? "{$name}?: {$this->type->write($context)}" + : "{$name}: {$this->type->write($context)}"; + } + + public function visitorProfile(): VisitorProfile + { + return VisitorProfile::create()->single('type'); + } +} diff --git a/src/TypeScriptNodes/TypeScriptProperty.php b/src/TypeScriptNodes/TypeScriptProperty.php new file mode 100644 index 0000000..8bdfac7 --- /dev/null +++ b/src/TypeScriptNodes/TypeScriptProperty.php @@ -0,0 +1,38 @@ +name = is_string($name) ? new TypeScriptIdentifier($name) : $name; + } + + public function write(WritingContext $context): string + { + $readonly = $this->isReadonly ? 'readonly ' : ''; + $optional = $this->isOptional ? '?' : ''; + + return "{$readonly}{$this->name->write($context)}{$optional}: {$this->type->write($context)}"; + } + + public function children(): array + { + return [$this->name, $this->type]; + } + + public function visitorProfile(): VisitorProfile + { + return VisitorProfile::create()->single('name', 'type'); + } +} diff --git a/src/TypeScriptNodes/TypeScriptRaw.php b/src/TypeScriptNodes/TypeScriptRaw.php new file mode 100644 index 0000000..9f6b23f --- /dev/null +++ b/src/TypeScriptNodes/TypeScriptRaw.php @@ -0,0 +1,18 @@ +typeScript; + } +} diff --git a/src/TypeScriptNodes/TypeScriptString.php b/src/TypeScriptNodes/TypeScriptString.php new file mode 100644 index 0000000..470e5cf --- /dev/null +++ b/src/TypeScriptNodes/TypeScriptString.php @@ -0,0 +1,13 @@ + $types + */ + public function __construct( + public array $types, + ) { + } + + public function write(WritingContext $context): string + { + return implode(' | ', array_map( + fn (TypeScriptNode $type) => $type->write($context), + $this->types + )); + } + + public function contains(Closure $closure): bool + { + foreach ($this->types as $type) { + if ($closure($type)) { + return true; + } + } + + return false; + } + + public function visitorProfile(): VisitorProfile + { + return VisitorProfile::create()->iterable('types'); + } +} diff --git a/src/TypeScriptNodes/TypeScriptUnknown.php b/src/TypeScriptNodes/TypeScriptUnknown.php new file mode 100644 index 0000000..07f56df --- /dev/null +++ b/src/TypeScriptNodes/TypeScriptUnknown.php @@ -0,0 +1,13 @@ +get() : $config; + + $log = new TypeScriptTransformerLog($console); + + return new self( + $config, + $log, + new DiscoverTypesAction(), + new ProvideTypesAction($config), + new ExecuteProvidedClosuresAction($config), + new ConnectReferencesAction($log), + new ExecuteConnectedClosuresAction($config), + new WriteFilesAction($config), + new FormatFilesAction($config), + new TransformTypesAction(), + new LoadPhpClassNodeAction(), + $watch + ); + } + + public function execute(): void { - $this->config = $config; + /** + * TODO: + * - Add interface implementation + tests -> OK + * - Split off Laravel specific code and test + * - Split off data specific code and test + * - Add support for watching files + * - Further write docs + check them -> only Laravel specific stuff + * - Check old Laravel tests if we missed something + * - Check in Flare whether everything is working as expected -> PR ready, needs fixing TS + * - Make sure nullables can be exported as optional: https://github.com/spatie/typescript-transformer/pull/88/files + * - Release + */ + + $transformedCollection = $this->resolveTransformedCollection(); + + $this->outputTransformed($transformedCollection); + + if ($this->watch) { + $watcher = new FileSystemWatcher( + $this, + $transformedCollection, + ); + + $watcher->run(); + } } - public function transform(): TypesCollection + public function resolveTransformedCollection(): TransformedCollection { - $typesCollection = (new ResolveTypesCollectionAction( - new Finder(), - $this->config, - ))->execute(); + $transformedCollection = $this->provideTypesAction->execute(); + + $this->executeProvidedClosuresAction->execute($transformedCollection); + + $this->connectReferencesAction->execute($transformedCollection); + + $this->executeConnectedClosuresAction->execute($transformedCollection); + + return $transformedCollection; + } + + public function outputTransformed( + TransformedCollection $transformedCollection, + ): void { + if (! $transformedCollection->hasChanges()) { + return; + } - (new PersistTypesCollectionAction($this->config))->execute($typesCollection); + $writeableFiles = $this->config->writer->output($transformedCollection); - (new FormatTypeScriptAction($this->config))->execute(); + $this->writeFilesAction->execute($writeableFiles); - return $typesCollection; + $this->formatFilesAction->execute($writeableFiles); } } diff --git a/src/TypeScriptTransformerConfig.php b/src/TypeScriptTransformerConfig.php old mode 100644 new mode 100755 index 0d0c763..9b51f11 --- a/src/TypeScriptTransformerConfig.php +++ b/src/TypeScriptTransformerConfig.php @@ -2,164 +2,29 @@ namespace Spatie\TypeScriptTransformer; -use phpDocumentor\Reflection\Type; -use phpDocumentor\Reflection\TypeResolver; -use Spatie\TypeScriptTransformer\Collectors\DefaultCollector; -use Spatie\TypeScriptTransformer\Exceptions\InvalidDefaultTypeReplacer; use Spatie\TypeScriptTransformer\Formatters\Formatter; use Spatie\TypeScriptTransformer\Transformers\Transformer; -use Spatie\TypeScriptTransformer\Writers\TypeDefinitionWriter; +use Spatie\TypeScriptTransformer\TypeProviders\TypesProvider; +use Spatie\TypeScriptTransformer\Visitor\VisitorClosure; use Spatie\TypeScriptTransformer\Writers\Writer; class TypeScriptTransformerConfig { - private array $autoDiscoverTypesPaths = []; - - private array $transformers = []; - - private array $collectors = [DefaultCollector::class]; - - private string $outputFile = 'types.d.ts'; - - private array $defaultTypeReplacements = []; - - private string $writer = TypeDefinitionWriter::class; - - private ?string $formatter = null; - - private bool $transformToNativeEnums = false; - - public static function create(): self - { - return new self(); - } - - public function autoDiscoverTypes(string ...$paths): self - { - $this->autoDiscoverTypesPaths = array_merge($this->autoDiscoverTypesPaths, $paths); - - return $this; - } - - public function transformers(array $transformers): self - { - $this->transformers = $transformers; - - return $this; - } - - public function collectors(array $collectors) - { - $this->collectors = array_merge($collectors, [DefaultCollector::class]); - - return $this; - } - - public function writer(string $writer): self - { - $this->writer = $writer; - - return $this; - } - - public function outputFile(string $defaultFile): self - { - $this->outputFile = $defaultFile; - - return $this; - } - - public function defaultTypeReplacements(array $defaultTypeReplacements): self - { - $this->defaultTypeReplacements = $defaultTypeReplacements; - - return $this; - } - - public function formatter(?string $formatter): self - { - $this->formatter = $formatter; - - return $this; - } - - public function transformToNativeEnums(bool $transformToNativeEnums = true): self - { - $this->transformToNativeEnums = $transformToNativeEnums; - - return $this; - } - - public function getAutoDiscoverTypesPaths(): array - { - return $this->autoDiscoverTypesPaths; - } - - /**@return \Spatie\TypeScriptTransformer\Transformers\Transformer[] */ - public function getTransformers(): array - { - return array_map( - fn (string $transformer) => $this->buildTransformer($transformer), - $this->transformers - ); - } - - public function buildTransformer(string $transformer): Transformer - { - return method_exists($transformer, '__construct') - ? new $transformer($this) - : new $transformer; - } - - public function getWriter(): Writer - { - return new $this->writer; - } - - public function getOutputFile(): string - { - return $this->outputFile; - } - - /** @return \Spatie\TypeScriptTransformer\Collectors\Collector[] */ - public function getCollectors(): array - { - return array_map( - fn (string $collector) => new $collector($this), - $this->collectors - ); - } - - public function getDefaultTypeReplacements(): array - { - $typeResolver = new TypeResolver(); - - $replacements = []; - - foreach ($this->defaultTypeReplacements as $class => $replacement) { - if (! class_exists($class) && ! interface_exists($class)) { - throw InvalidDefaultTypeReplacer::classDoesNotExist($class); - } - - $replacements[$class] = $replacement instanceof Type - ? $replacement - : $typeResolver->resolve($replacement); - } - - return $replacements; - } - - public function getFormatter(): ?Formatter - { - if ($this->formatter === null) { - return null; - } - - return new $this->formatter; - } - - public function shouldTransformToNativeEnums(): bool - { - return $this->transformToNativeEnums; + /** + * @param array $typeProviders + * @param array $directoriesToWatch + * @param array $providedVisitorClosures + * @param array $connectedVisitorClosures + * @param array $transformers + */ + public function __construct( + readonly public array $typeProviders, + readonly public Writer $writer, + readonly public ?Formatter $formatter, + readonly public array $directoriesToWatch = [], + readonly public array $providedVisitorClosures = [], + readonly public array $connectedVisitorClosures = [], + readonly public array $transformers = [], + ) { } } diff --git a/src/TypeScriptTransformerConfigFactory.php b/src/TypeScriptTransformerConfigFactory.php new file mode 100644 index 0000000..055d4d9 --- /dev/null +++ b/src/TypeScriptTransformerConfigFactory.php @@ -0,0 +1,247 @@ + $typeProviders + * @param array $transformers + * @param array $directoriesToWatch + * @param array $typeReplacements + * @param array, TypeScriptTransformerExtension> $extensions + * @param array $providedVisitorClosures + * @param array $connectedVisitorClosures + */ + public function __construct( + protected array $typeProviders = [], + protected string|Writer|null $writer = null, + protected string|Formatter|null $formatter = null, + protected array $transformers = [], + protected array $directoriesToWatch = [], + protected array $typeReplacements = [], + protected array $extensions = [], + protected array $providedVisitorClosures = [], + protected array $connectedVisitorClosures = [], + ) { + } + + public static function create(): self + { + return new self(); + } + + public function typesProvider(TypesProvider|string ...$typesProvider): self + { + foreach ($typesProvider as $provider) { + if ($provider === TransformerTypesProvider::class || $provider instanceof TransformerTypesProvider) { + throw new Exception("Please add transformers using the config's `transformer` method."); + } + } + + array_push($this->typeProviders, ...$typesProvider); + + return $this; + } + + public function transformer(string|Transformer ...$transformer): self + { + array_push($this->transformers, ...$transformer); + + return $this; + } + + public function prependTransformer(string|Transformer ...$transformer): self + { + array_unshift($this->transformers, ...$transformer); + + return $this; + } + + public function replaceTransformer( + string|Transformer $search, + string|Transformer $replacement + ): self { + $searchClass = is_string($search) ? $search : $search::class; + + foreach ($this->transformers as $key => $transformer) { + if (is_string($transformer) && $transformer === $searchClass) { + $this->transformers[$key] = $replacement; + + break; + } + + if (is_object($transformer) && $transformer::class === $searchClass) { + $this->transformers[$key] = $replacement; + } + } + + return $this; + } + + public function watchDirectories(string ...$directories): self + { + array_push($this->directoriesToWatch, ...$directories); + + return $this; + } + + public function writer(Writer $writer): self + { + $this->writer = $writer; + + return $this; + } + + public function formatter(Formatter|string $formatter): self + { + $this->formatter = $formatter; + + return $this; + } + + public function providedVisitorHook( + VisitorClosure|Closure $visitor, + ?array $allowedNodes = null, + VisitorClosureType $type = VisitorClosureType::Before + ): self { + if (! $visitor instanceof VisitorClosure) { + $visitor = new VisitorClosure($visitor, $allowedNodes, $type); + } + + $this->providedVisitorClosures[] = $visitor; + + return $this; + } + + public function connectedVisitorHook( + VisitorClosure|Closure $visitor, + ?array $allowedNodes = null, + VisitorClosureType $type = VisitorClosureType::Before + ): self { + if (! $visitor instanceof VisitorClosure) { + $visitor = new VisitorClosure($visitor, $allowedNodes, $type); + } + + $this->connectedVisitorClosures[] = $visitor; + + return $this; + } + + public function replaceType( + string $search, + TypeScriptNode|string|Closure $replacement + ): self { + if ($replacement instanceof TypeScriptNode) { + $this->typeReplacements[$search] = $replacement; + + return $this; + } + + if (is_string($replacement)) { + try { + $node = ParseUserDefinedTypeAction::instance()->execute($replacement); + + if ($node instanceof TypeScriptUnknown) { + $node = new TypeScriptRaw($replacement); + } + + $this->typeReplacements[$search] = $node; + } catch (Throwable $e) { + $this->typeReplacements[$search] = new TypeScriptRaw($replacement); + } + + return $this; + } + + if (! $replacement instanceof Closure) { + throw new Exception('Replacement must be a TypeScriptNode, a string or a Closure'); + } + + $this->typeReplacements[$search] = $replacement; + + return $this; + } + + public function extension( + TypeScriptTransformerExtension ...$extensions + ): self { + foreach ($extensions as $extension) { + if (array_key_exists($extension::class, $this->extensions)) { + continue; + } + + $this->extensions[$extension::class] = $extension; + + $extension->enrich($this); + } + + return $this; + } + + public function get(): TypeScriptTransformerConfig + { + $this->ensureConfigIsValid(); + + $typeProviders = array_map( + fn (TypesProvider|string $typeProvider) => is_string($typeProvider) ? new $typeProvider() : $typeProvider, + $this->typeProviders + ); + + $writer = $this->writer ?? new NamespaceWriter(__DIR__.'/js/typed.ts'); + + if (is_string($writer)) { + $writer = new $writer(); + } + + $formatter = is_string($this->formatter) ? new $this->formatter() : $this->formatter; + + if ($this->typeReplacements) { + array_unshift($this->providedVisitorClosures, new ReplaceTypesVisitorClosure($this->typeReplacements)); + } + + $transformers = array_map( + fn (Transformer|string $transformer) => is_string($transformer) ? new $transformer() : $transformer, + $this->transformers + ); + + if (! empty($transformers)) { + $typeProviders[] = new TransformerTypesProvider($transformers, $this->directoriesToWatch); + } + + return new TypeScriptTransformerConfig( + $typeProviders, + $writer, + $formatter, + $this->directoriesToWatch, + $this->providedVisitorClosures, + $this->connectedVisitorClosures, + $transformers, + ); + } + + protected function ensureConfigIsValid(): void + { + if (! empty($this->transformers) && empty($this->directoriesToWatch)) { + throw new \Exception('When using transformers, you must specify which directories to watch'); + } + } +} diff --git a/src/Types/RecordType.php b/src/Types/RecordType.php deleted file mode 100644 index d385c5c..0000000 --- a/src/Types/RecordType.php +++ /dev/null @@ -1,42 +0,0 @@ -keyType = (new TypeResolver())->resolve($keyType); - - if ($array) { - $this->valueType = new Array_((new TypeResolver())->resolve($valueType)); - } else { - $this->valueType = is_array($valueType) - ? StructType::fromArray($valueType) - : (new TypeResolver())->resolve($valueType); - } - } - - public function __toString(): string - { - return 'record'; - } - - public function getKeyType(): Type - { - return $this->keyType; - } - - public function getValueType(): Type - { - return $this->valueType; - } -} diff --git a/src/Types/StructType.php b/src/Types/StructType.php deleted file mode 100644 index 72e1067..0000000 --- a/src/Types/StructType.php +++ /dev/null @@ -1,54 +0,0 @@ - */ - private array $types; - - public static function fromArray(array $properties): self - { - $resolver = new TypeResolver(); - - $types = []; - - foreach ($properties as $name => $property) { - if (is_string($property)) { - $types[$name] = $resolver->resolve($property); - - continue; - } - - if (is_array($property)) { - $types[$name] = self::fromArray($property); - - continue; - } - - throw UnableToTransformUsingAttribute::create($property); - } - - return new self($types); - } - - public function __construct(array $types) - { - $this->types = $types; - } - - public function __toString(): string - { - return 'struct'; - } - - public function getTypes(): array - { - return $this->types; - } -} diff --git a/src/Types/TypeScriptType.php b/src/Types/TypeScriptType.php deleted file mode 100644 index 5d5b5ef..0000000 --- a/src/Types/TypeScriptType.php +++ /dev/null @@ -1,26 +0,0 @@ -typeScript = $typeScript; - } - - public function __toString(): string - { - return $this->typeScript; - } -} diff --git a/src/Visitor/Common/ReplaceTypesVisitorClosure.php b/src/Visitor/Common/ReplaceTypesVisitorClosure.php new file mode 100644 index 0000000..66dbc5c --- /dev/null +++ b/src/Visitor/Common/ReplaceTypesVisitorClosure.php @@ -0,0 +1,76 @@ + */ + protected static array $replacements = []; + + /** @var array */ + protected static array $skip = []; + + /** + * @param array $typeReplacements + */ + public function __construct( + protected array $typeReplacements + ) { + parent::__construct( + $this->resolveClosure(), + allowedNodes: [TypeReference::class], + type: VisitorClosureType::Before + ); + + static::$replacements = $typeReplacements; + } + + protected function resolveClosure(): Closure + { + return function (TypeReference $node) { + if (! $node->reference instanceof ClassStringReference) { + return $node; + } + + $class = $node->reference->classString; + + if (array_key_exists($class, static::$skip)) { + return $node; + } + + if (! array_key_exists($class, static::$replacements)) { + foreach ($this->typeReplacements as $type => $replacement) { + if ($class === $type || is_a($class, $type, true)) { + static::$replacements[$class] = $replacement; + + return $this->replaceNode($node, $replacement); + } + } + + return $node; + } + + return $this->replaceNode($node, static::$replacements[$class]); + }; + } + + protected function replaceNode( + TypeReference $node, + Closure|TypeScriptNode $replacement, + ): VisitorOperation { + if ($replacement instanceof Closure) { + $replacement = $replacement($node); + } + + return VisitorOperation::replace($replacement); + } +} diff --git a/src/Visitor/Visitor.php b/src/Visitor/Visitor.php new file mode 100644 index 0000000..fd6e06e --- /dev/null +++ b/src/Visitor/Visitor.php @@ -0,0 +1,121 @@ + $closures + */ + public function __construct( + protected array $closures = [], + ) { + } + + public function before( + Closure $closure, + ?array $allowedNodes = null, + ): self { + $this->closures[] = new VisitorClosure($closure, $allowedNodes, VisitorClosureType::Before); + + return $this; + } + + public function after( + Closure $closure, + ?array $allowedNodes = null, + ): self { + $this->closures[] = new VisitorClosure($closure, $allowedNodes, VisitorClosureType::After); + + return $this; + } + + public function closures( + VisitorClosure ...$closures + ): self { + array_push($this->closures, ...$closures); + + return $this; + } + + public function execute( + TypeScriptNode $node, + array &$metadata = [], + ): ?TypeScriptNode { + foreach ($this->closures as $closure) { + if (! $closure->isBefore()) { + continue; + } + + if ($closure->shouldRun($node)) { + $operation = $closure->run($node, $metadata); + + if ($operation->type === VisitorOperationType::Remove) { + return null; + } + + if ($operation->type === VisitorOperationType::Replace) { + return $operation->node; + } + } + } + + if ($node instanceof TypeScriptVisitableNode) { + $profile = $node->visitorProfile(); + + foreach ($profile->singleNodes as $singleNodeName) { + $subNode = $node->$singleNodeName; + + if ($subNode === null) { + continue; + } + + $visited = $this->execute($subNode, $metadata); + + try { + $node->$singleNodeName = $visited; + } catch (Exception $e) { + throw new Exception("Tried setting $singleNodeName on ".get_class($node).' to '.get_class($visited).' but failed.'); + } + } + + foreach ($profile->iterableNodes as $iterableNodeName) { + foreach ($node->$iterableNodeName as $key => $subNode) { + $node->$iterableNodeName[$key] = $this->execute($subNode, $metadata); + } + + $node->$iterableNodeName = array_values(array_filter($node->$iterableNodeName)); + } + } + + foreach ($this->closures as $closure) { + if (! $closure->isAfter()) { + continue; + } + + if ($closure->shouldRun($node)) { + $operation = $closure->run($node, $metadata); + + if ($operation->type === VisitorOperationType::Remove) { + return null; + } + + if ($operation->type === VisitorOperationType::Replace) { + return $operation->node; + } + } + } + + return $node; + } +} diff --git a/src/Visitor/VisitorClosure.php b/src/Visitor/VisitorClosure.php new file mode 100644 index 0000000..c57cc94 --- /dev/null +++ b/src/Visitor/VisitorClosure.php @@ -0,0 +1,55 @@ +requiresMetadata = ReflectionFunction::createFromClosure($this->closure)->getNumberOfParameters() === 2; + } + + public function isBefore(): bool + { + return $this->type === VisitorClosureType::Before; + } + + public function isAfter(): bool + { + return $this->type === VisitorClosureType::After; + } + + public function shouldRun( + TypeScriptNode $node + ): bool { + if ($this->allowedNodes === null) { + return true; + } + + return in_array(get_class($node), $this->allowedNodes); + } + + public function run( + TypeScriptNode $node, + array &$metadata, + ): VisitorOperation { + $output = $this->requiresMetadata + ? ($this->closure)($node, $metadata) + : ($this->closure)($node); + + if ($output instanceof VisitorOperation) { + return $output; + } + + return VisitorOperation::keep(); + } +} diff --git a/src/Visitor/VisitorClosureType.php b/src/Visitor/VisitorClosureType.php new file mode 100644 index 0000000..544b73f --- /dev/null +++ b/src/Visitor/VisitorClosureType.php @@ -0,0 +1,9 @@ +get($reference); + + if (empty($transformable->location)) { + return $transformable->getName(); + } + + return $transformable->getName(); + }); + + foreach ($collection as $transformed) { + $output .= $transformed->prepareForWrite()->write($writingContext).PHP_EOL; + } + + return [new WriteableFile($this->filename, $output)]; + } +} diff --git a/src/Writers/ModuleWriter.php b/src/Writers/ModuleWriter.php index e0c0c46..827d323 100644 --- a/src/Writers/ModuleWriter.php +++ b/src/Writers/ModuleWriter.php @@ -2,35 +2,84 @@ namespace Spatie\TypeScriptTransformer\Writers; -use Spatie\TypeScriptTransformer\Structures\TransformedType; -use Spatie\TypeScriptTransformer\Structures\TypesCollection; +use Spatie\TypeScriptTransformer\Actions\ResolveModuleImportsAction; +use Spatie\TypeScriptTransformer\Actions\SplitTransformedPerLocationAction; +use Spatie\TypeScriptTransformer\Collections\TransformedCollection; +use Spatie\TypeScriptTransformer\References\Reference; +use Spatie\TypeScriptTransformer\Support\Location; +use Spatie\TypeScriptTransformer\Support\WriteableFile; +use Spatie\TypeScriptTransformer\Support\WritingContext; class ModuleWriter implements Writer { - public function format(TypesCollection $collection): string + public function __construct( + protected string $path, + protected SplitTransformedPerLocationAction $transformedPerLocationAction = new SplitTransformedPerLocationAction(), + protected ResolveModuleImportsAction $resolveModuleImportsAction = new ResolveModuleImportsAction(), + ) { + } + + public function output(TransformedCollection $collection): array { + $locations = $this->transformedPerLocationAction->execute( + $collection + ); + + $writtenFiles = []; + + // TODO: remove files which still exists due to previous run + + foreach ($locations as $location) { + $writtenFiles[] = $this->writeLocation($location, $collection); + } + + // TODO: we probably can be a bit smarter about this + // -> only write files which have changed + + return $writtenFiles; + } + + protected function writeLocation( + Location $location, + TransformedCollection $collection, + ): WriteableFile { + $imports = $this->resolveModuleImportsAction->execute($location, $collection); + $output = ''; - /** @var \ArrayIterator $iterator */ - $iterator = $collection->getIterator(); + $writingContext = new WritingContext(function (Reference $reference) use ($collection, $imports) { + if ($name = $imports->getAliasOrNameForReference($reference)) { + return $name; + } - $iterator->uasort(function (TransformedType $a, TransformedType $b) { - return strcmp($a->name, $b->name); + // Type declared somewhere else in the module + return $collection->get($reference)->getName(); }); - foreach ($iterator as $type) { - if ($type->isInline) { - continue; - } + foreach ($imports->getTypeScriptNodes() as $import) { + $output .= $import->write($writingContext).PHP_EOL; + } + + if ($imports->isEmpty() === false) { + $output .= PHP_EOL; + } - $output .= "export {$type->toString()}".PHP_EOL; + foreach ($location->transformed as $transformedItem) { + $output .= $transformedItem->prepareForWrite()->write($writingContext).PHP_EOL; } - return $output; + return new WriteableFile("{$this->resolvePath($location)}/index.ts", $output); } - public function replacesSymbolsWithFullyQualifiedIdentifiers(): bool - { - return false; + protected function resolvePath( + Location $location, + ): string { + $basePath = rtrim($this->path, '/'); + + if (count($location->segments) === 0) { + return $basePath; + } + + return $basePath.'/'.implode('/', $location->segments); } } diff --git a/src/Writers/NamespaceWriter.php b/src/Writers/NamespaceWriter.php new file mode 100644 index 0000000..a4f2235 --- /dev/null +++ b/src/Writers/NamespaceWriter.php @@ -0,0 +1,64 @@ +splitTransformedPerLocationAction = new SplitTransformedPerLocationAction(); + } + + public function output( + TransformedCollection $collection, + ): array { + $split = $this->splitTransformedPerLocationAction->execute( + $collection + ); + + $output = ''; + + $writingContext = new WritingContext(function (Reference $reference) use ($collection) { + $transformable = $collection->get($reference); + + if (empty($transformable->location)) { + return $transformable->getName(); + } + + return implode('.', $transformable->location).'.'.$transformable->getName(); + }); + + foreach ($split as $splitConstruct) { + if (count($splitConstruct->segments) === 0) { + foreach ($splitConstruct->transformed as $transformable) { + $output .= $transformable->prepareForWrite()->write($writingContext) . PHP_EOL; + } + + continue; + } + + $namespace = new TypeScriptNamespace( + $splitConstruct->segments, + array_map( + fn (Transformed $transformable) => $transformable->prepareForWrite(), + $splitConstruct->transformed, + ), + ); + + $output .= $namespace->write($writingContext) . PHP_EOL; + } + + return [new WriteableFile($this->filename, $output)]; + } +} diff --git a/src/Writers/TypeDefinitionWriter.php b/src/Writers/TypeDefinitionWriter.php deleted file mode 100644 index 1a02989..0000000 --- a/src/Writers/TypeDefinitionWriter.php +++ /dev/null @@ -1,73 +0,0 @@ -execute($collection); - - [$namespaces, $rootTypes] = $this->groupByNamespace($collection); - - $output = ''; - - foreach ($namespaces as $namespace => $types) { - asort($types); - - $output .= "declare namespace {$namespace} {".PHP_EOL; - - $output .= join(PHP_EOL, array_map( - fn (TransformedType $type) => "export {$type->toString()}", - $types - )); - - - $output .= PHP_EOL."}".PHP_EOL; - } - - $output .= join(PHP_EOL, array_map( - fn (TransformedType $type) => "export {$type->toString()}", - $rootTypes - )); - - return $output; - } - - public function replacesSymbolsWithFullyQualifiedIdentifiers(): bool - { - return true; - } - - protected function groupByNamespace(TypesCollection $collection): array - { - $namespaces = []; - $rootTypes = []; - - foreach ($collection as $type) { - if ($type->isInline) { - continue; - } - - $namespace = str_replace('\\', '.', $type->reflection->getNamespaceName()); - - if (empty($namespace)) { - $rootTypes[] = $type; - - continue; - } - - array_key_exists($namespace, $namespaces) - ? $namespaces[$namespace][] = $type - : $namespaces[$namespace] = [$type]; - } - - ksort($namespaces); - - return [$namespaces, $rootTypes]; - } -} diff --git a/src/Writers/Writer.php b/src/Writers/Writer.php index 639200f..f65a632 100644 --- a/src/Writers/Writer.php +++ b/src/Writers/Writer.php @@ -2,11 +2,13 @@ namespace Spatie\TypeScriptTransformer\Writers; -use Spatie\TypeScriptTransformer\Structures\TypesCollection; +use Spatie\TypeScriptTransformer\Collections\TransformedCollection; +use Spatie\TypeScriptTransformer\Support\WriteableFile; interface Writer { - public function format(TypesCollection $collection): string; - - public function replacesSymbolsWithFullyQualifiedIdentifiers(): bool; + /** @return array */ + public function output( + TransformedCollection $collection, + ): array; } diff --git a/stubs/TypeScriptTransformerServiceProvider.stub b/stubs/TypeScriptTransformerServiceProvider.stub new file mode 100644 index 0000000..aa68177 --- /dev/null +++ b/stubs/TypeScriptTransformerServiceProvider.stub @@ -0,0 +1,23 @@ +transformer(AttributedClassTransformer::class) + ->transformer(EnumTransformer::class) + ->watchDirectories(app_path()) + ->writer(new NamespaceWriter(resource_path('types/generated.d.ts'))) + ->formatter(PrettierFormatter::class); + } +} diff --git a/tests/Actions/ConnectReferencesActionTest.php b/tests/Actions/ConnectReferencesActionTest.php new file mode 100644 index 0000000..2a30c24 --- /dev/null +++ b/tests/Actions/ConnectReferencesActionTest.php @@ -0,0 +1,94 @@ +execute($collection); + + expect($transformedEnum->references)->toHaveCount(0); + expect($transformedEnum->referencedBy)->toHaveCount(1); + expect($transformedEnum->referencedBy)->toContain($transformedClass->reference->getKey()); + + expect($transformedClass->references)->toHaveCount(1); + expect($transformedClass->references)->toHaveKey($transformedEnum->reference->getKey()); + expect($transformedClass->referencedBy)->toHaveCount(0); + + expect($transformedClass->typeScriptNode->type->properties[0]->type) + ->toBeInstanceOf(TypeReference::class) + ->referenced->toBe($transformedEnum); +}); + +it('can connect two objects referencing each other', function () { + $collection = new TransformedCollection([ + $circularA = transformSingle(CircularA::class, new AllClassTransformer()), + $circularB = transformSingle(CircularB::class, new AllClassTransformer()), + ]); + + $action = new ConnectReferencesAction(TypeScriptTransformerLog::createNullLog()); + + $action->execute($collection); + + expect($circularA->references)->toHaveCount(1); + expect($circularA->references)->toHaveKey($circularB->reference->getKey()); + expect($circularA->referencedBy)->toHaveCount(1); + expect($circularA->referencedBy)->toContain($circularB->reference->getKey()); + + expect($circularB->references)->toHaveCount(1); + expect($circularB->references)->toHaveKey($circularA->reference->getKey()); + expect($circularB->referencedBy)->toHaveCount(1); + expect($circularB->referencedBy)->toContain($circularA->reference->getKey()); + + expect($circularA->typeScriptNode->type->properties[0]->type) + ->toBeInstanceOf(TypeReference::class) + ->referenced->toBe($circularB); + + expect($circularB->typeScriptNode->type->properties[0]->type) + ->toBeInstanceOf(TypeReference::class) + ->referenced->toBe($circularA); +}); + +it('will write to the log when a reference cannot be found', function () { + $class = new class () { + public StringBackedEnum $enum; + }; + + $collection = new TransformedCollection([ + $transformedClass = transformSingle($class, new AllClassTransformer()), + ]); + + + $action = new ConnectReferencesAction( + new TypeScriptTransformerLog($console = new WrappedArrayConsole()) + ); + + $action->execute($collection); + + expect($transformedClass->references)->toHaveCount(0); + expect($transformedClass->referencedBy)->toHaveCount(0); + + expect($transformedClass->typeScriptNode->type->properties[0]->type) + ->toBeInstanceOf(TypeReference::class) + ->referenced->toBeNull(); + + expect($console->messages)->not()->toBeEmpty(); +}); diff --git a/tests/Actions/DiscoverTypesActionTest.php b/tests/Actions/DiscoverTypesActionTest.php new file mode 100644 index 0000000..6a142c5 --- /dev/null +++ b/tests/Actions/DiscoverTypesActionTest.php @@ -0,0 +1,36 @@ +execute([ + __DIR__.'/../Fakes/TypesToProvide', + ]); + + expect($types)->toEqual([ + new PhpEnumNode(new ReflectionEnum(StringBackedEnum::class)), + new PhpClassNode(new ReflectionClass(HiddenAttributedClass::class)), + new PhpClassNode(new ReflectionClass(TypeScriptAttributedClass::class)), + new PhpClassNode(new ReflectionClass(SimpleInterface::class)), + new PhpClassNode(new ReflectionClass(TypeScriptLocationAttributedClass::class)), + new PhpClassNode(new ReflectionClass(OptionalAttributedClass::class)), + new PhpEnumNode(new ReflectionEnum(EmptyEnum::class)), + new PhpClassNode(new ReflectionClass(ReadonlyClass::class)), + new PhpClassNode(new ReflectionClass(SimpleClass::class)), + new PhpEnumNode(new ReflectionEnum(UnitEnum::class)), + new PhpEnumNode(new ReflectionEnum(IntBackedEnum::class)), + ]); +}); diff --git a/tests/Actions/FormatFilesActionTest.php b/tests/Actions/FormatFilesActionTest.php new file mode 100644 index 0000000..9dfd79f --- /dev/null +++ b/tests/Actions/FormatFilesActionTest.php @@ -0,0 +1,71 @@ +temporaryDirectory = (new TemporaryDirectory())->create(); + + $this->outputFile = $this->temporaryDirectory->path('types.d.ts'); +}); + +it('can format an generated file with prettier', function () { + $writeableFileA = new WriteableFile( + $this->temporaryDirectory->path('testA.ts'), + "export type Enum='yes'|'no';export type OtherDto={name:string}" + ); + + $writeableFileB = new WriteableFile( + $this->temporaryDirectory->path('testA.ts'), + '{int: number;overwritable: number | boolean;object: {an_int:number;a_bool:boolean;}pure_typescript: never;pure_typescript_object: {an_any:any;a_never:never;}regular_type: number;}' + ); + + file_put_contents($writeableFileA->path, $writeableFileA->contents); + file_put_contents($writeableFileB->path, $writeableFileB->contents); + + $action = new FormatFilesAction( + TypeScriptTransformerConfigFactory::create() + ->formatter(PrettierFormatter::class) + ->get() + ); + + $action->execute([ + $writeableFileA, + $writeableFileB, + ]); + + assertMatchesSnapshot(file_get_contents($writeableFileA->path)); + assertMatchesSnapshot(file_get_contents($writeableFileB->path)); +}); + +it('can disable formatting', function () { + $writeableFileA = new WriteableFile( + $this->temporaryDirectory->path('testA.ts'), + "export type Enum='yes'|'no';export type OtherDto={name:string}" + ); + + $writeableFileB = new WriteableFile( + $this->temporaryDirectory->path('testA.ts'), + '{int: number;overwritable: number | boolean;object: {an_int:number;a_bool:boolean;}pure_typescript: never;pure_typescript_object: {an_any:any;a_never:never;}regular_type: number;}' + ); + + file_put_contents($writeableFileA->path, $writeableFileA->contents); + file_put_contents($writeableFileB->path, $writeableFileB->contents); + + $action = new FormatFilesAction( + TypeScriptTransformerConfigFactory::create()->get() + ); + + $action->execute([ + $writeableFileA, + $writeableFileB, + ]); + + assertMatchesSnapshot(file_get_contents($writeableFileA->path)); + assertMatchesSnapshot(file_get_contents($writeableFileB->path)); +}); diff --git a/tests/Actions/FormatTypeScriptActionTest.php b/tests/Actions/FormatTypeScriptActionTest.php deleted file mode 100644 index b5d8276..0000000 --- a/tests/Actions/FormatTypeScriptActionTest.php +++ /dev/null @@ -1,53 +0,0 @@ -temporaryDirectory = (new TemporaryDirectory())->create(); - - $this->outputFile = $this->temporaryDirectory->path('types.d.ts'); -}); - -it('can format an generated file', function () { - $formatter = new class implements Formatter { - public function format(string $file): void - { - file_put_contents($file, 'formatted'); - } - }; - - $action = new FormatTypeScriptAction( - TypeScriptTransformerConfig::create() - ->formatter($formatter::class) - ->outputFile($this->outputFile) - ); - - file_put_contents( - $this->outputFile, - "export type Enum='yes'|'no';export type OtherDto={name:string}" - ); - - $action->execute(); - - assertEquals('formatted', file_get_contents($this->outputFile)); -}); - -it('can disable formatting', function () { - $action = new FormatTypeScriptAction( - TypeScriptTransformerConfig::create()->outputFile($this->outputFile) - ); - - file_put_contents( - $this->outputFile, - "export type Enum='yes'|'no';export type OtherDto={name:string}" - ); - - $action->execute(); - - assertMatchesFileSnapshot($this->outputFile); -}); diff --git a/tests/Actions/ParseUserDefinedTypeActionTest.php b/tests/Actions/ParseUserDefinedTypeActionTest.php new file mode 100644 index 0000000..a59698c --- /dev/null +++ b/tests/Actions/ParseUserDefinedTypeActionTest.php @@ -0,0 +1,16 @@ +execute('string'))->toBeInstanceOf(TypeScriptString::class); + expect($parser->execute('array'))->toEqual(new TypeScriptArray([new TypeScriptString()])); + expect($parser->execute('self', PhpClassNode::fromReflection(new ReflectionClass(DateTime::class))))->toEqual(new TypeReference(new ClassStringReference(DateTime::class))); +}); diff --git a/tests/Actions/PersistTypesCollectionActionTest.php b/tests/Actions/PersistTypesCollectionActionTest.php deleted file mode 100644 index dacaf4f..0000000 --- a/tests/Actions/PersistTypesCollectionActionTest.php +++ /dev/null @@ -1,59 +0,0 @@ -temporaryDirectory = (new TemporaryDirectory())->create(); - - $this->action = new PersistTypesCollectionAction( - TypeScriptTransformerConfig::create() - ->autoDiscoverTypes(__DIR__ . '/../FakeClasses') - ->transformers([MyclabsEnumTransformer::class]) - ->outputFile($this->temporaryDirectory->path('types.d.ts')) - ); -}); - -it('will persist the types', function () { - $collection = TypesCollection::create(); - - $collection[] = FakeTransformedType::fake('Enum')->withoutNamespace(); - $collection[] = FakeTransformedType::fake('Enum')->withNamespace('test'); - $collection[] = FakeTransformedType::fake('Enum')->withNamespace('test\test'); - - $this->action->execute($collection); - - assertMatchesFileSnapshot($this->temporaryDirectory->path("types.d.ts")); -}); - -it('can persist multiple types in one namespace', function () { - $collection = TypesCollection::create(); - - $collection[] = FakeTransformedType::fake('Enum')->withTransformed('transformed Enum')->withoutNamespace(); - $collection[] = FakeTransformedType::fake('OtherEnum')->withTransformed('transformed OtherEnum')->withoutNamespace(); - $collection[] = FakeTransformedType::fake('Enum')->withTransformed('transformed test\Enum')->withNamespace('test'); - $collection[] = FakeTransformedType::fake('OtherEnum')->withTransformed('transformed test\OtherEnum')->withNamespace('test'); - - $this->action->execute($collection); - - assertMatchesFileSnapshot($this->temporaryDirectory->path("types.d.ts")); -}); - -it('can re save the file', function () { - $collection = TypesCollection::create(); - - $collection[] = FakeTransformedType::fake('Enum')->withoutNamespace(); - - $this->action->execute($collection); - - $collection[] = FakeTransformedType::fake('Enum')->withNamespace('test'); - - $this->action->execute($collection); - - assertMatchesFileSnapshot($this->temporaryDirectory->path("types.d.ts")); -}); diff --git a/tests/Actions/ProvideTypesActionTest.php b/tests/Actions/ProvideTypesActionTest.php new file mode 100644 index 0000000..5c49830 --- /dev/null +++ b/tests/Actions/ProvideTypesActionTest.php @@ -0,0 +1,41 @@ +add( + TransformedFactory::alias('Foo', new TypeScriptString())->build(), + ); + } + }; + + $config = TypeScriptTransformerConfigFactory::create() + ->typesProvider( + new InlineTypesProvider([ + TransformedFactory::alias('Bar', new TypeScriptString()), + ]), + $stringProvider::class + ) + ->get(); + + $types = (new ProvideTypesAction($config))->execute(); + + expect($types)->toHaveCount(2); + + $typesArray = array_values($types->all()); + + expect($typesArray[0]->getName())->toBe('Bar'); + expect($typesArray[1]->getName())->toBe('Foo'); +}); diff --git a/tests/Actions/ReplaceSymbolsInCollectionActionTest.php b/tests/Actions/ReplaceSymbolsInCollectionActionTest.php deleted file mode 100644 index 7d27361..0000000 --- a/tests/Actions/ReplaceSymbolsInCollectionActionTest.php +++ /dev/null @@ -1,42 +0,0 @@ -withNamespace('enums'); - $collection[] = FakeTransformedType::fake('Dto') - ->withTransformed('{enum: {%enums\Enum%}, non-existing: {%non-existing%}}') - ->withMissingSymbols([ - 'enum' => 'enums\Enum', - 'non-existing' => 'non-existing', - ]); - - $collection = $action->execute($collection); - - assertEquals('{enum: enums.Enum, non-existing: any}', $collection['Dto']->transformed); -}); - -it('can replace missing symbols without fully qualified names', function () { - $action = new ReplaceSymbolsInCollectionAction(); - - $collection = TypesCollection::create(); - - $collection[] = FakeTransformedType::fake('Enum')->withNamespace('enums'); - $collection[] = FakeTransformedType::fake('Dto') - ->withTransformed('{enum: {%enums\Enum%}, non-existing: {%non-existing%}}') - ->withMissingSymbols([ - 'enum' => 'enums\Enum', - 'non-existing' => 'non-existing', - ]); - - $collection = $action->execute($collection, false); - - assertEquals('{enum: Enum, non-existing: any}', $collection['Dto']->transformed); -}); diff --git a/tests/Actions/ReplaceSymbolsInTypeActionTest.php b/tests/Actions/ReplaceSymbolsInTypeActionTest.php deleted file mode 100644 index 3991fa6..0000000 --- a/tests/Actions/ReplaceSymbolsInTypeActionTest.php +++ /dev/null @@ -1,104 +0,0 @@ -collection = TypesCollection::create(); - - $this->action = new ReplaceSymbolsInTypeAction($this->collection); -}); - -it('can replace symbols', function () { - $typeC = FakeTransformedType::fake('C') - ->isInline() - ->withTransformed('This is type C'); - - $typeB = FakeTransformedType::fake('B') - ->isInline() - ->withMissingSymbols(['C' => 'C']) - ->withTransformed('Depends on type C: {%C%}'); - - $typeA = FakeTransformedType::fake('A') - ->isInline() - ->withMissingSymbols(['B' => 'B']) - ->withTransformed("Depends on type B: {%B%}"); - - $this->collection[] = $typeA; - $this->collection[] = $typeB; - $this->collection[] = $typeC; - - $transformed = $this->action->execute($typeA); - - assertEquals('Depends on type B: Depends on type C: This is type C', $transformed); - assertEquals('Depends on type C: This is type C', $this->collection['B']->transformed); - assertEquals('This is type C', $this->collection['C']->transformed); -}); - -it('will throw an exception when doing circular dependencies', function () { - $this->expectException(CircularDependencyChain::class); - - $typeA = FakeTransformedType::fake('A') - ->isInline() - ->withMissingSymbols(['B' => 'B']) - ->withTransformed("Depends on type B: {%B%}"); - - $typeB = FakeTransformedType::fake('B') - ->isInline() - ->withMissingSymbols(['A' => 'A']) - ->withTransformed('Depends on type A: {%A%}'); - - $this->collection[] = $typeA; - $this->collection[] = $typeB; - - $this->action->execute($typeA); -}); - -it('can replace non inline types circular', function () { - $typeB = FakeTransformedType::fake('B') - ->withMissingSymbols(['A' => 'A']) - ->withTransformed('Links to A: {%A%}'); - - $typeA = FakeTransformedType::fake('A') - ->withMissingSymbols(['B' => 'B']) - ->withTransformed('Links to B: {%B%}'); - - $this->collection[] = $typeA; - $this->collection[] = $typeB; - - $transformedA = $this->action->execute($typeA); - $transformedB = $this->action->execute($typeB); - - assertEquals('Links to B: B', $transformedA); - assertEquals('Links to A: A', $transformedB); -}); - -it('can inline multiple dependencies', function () { - $typeC = FakeTransformedType::fake('C') - ->isInline() - ->withTransformed('This is type C'); - - $typeB = FakeTransformedType::fake('B') - ->isInline() - ->withMissingSymbols(['C' => 'C']) - ->withTransformed('Depends on type C: {%C%}'); - - $typeA = FakeTransformedType::fake('A') - ->isInline() - ->withMissingSymbols(['B' => 'B', 'C' => 'C']) - ->withTransformed('Depends on type B: {%B%} | depends on type C: {%C%}'); - - $this->collection[] = $typeA; - $this->collection[] = $typeB; - $this->collection[] = $typeC; - - $transformed = $this->action->execute($typeA); - - assertEquals( - 'Depends on type B: Depends on type C: This is type C | depends on type C: This is type C', - $transformed - ); -}); diff --git a/tests/Actions/ResolveClassesInPhpFileActionTest.php b/tests/Actions/ResolveClassesInPhpFileActionTest.php deleted file mode 100644 index 4924f47..0000000 --- a/tests/Actions/ResolveClassesInPhpFileActionTest.php +++ /dev/null @@ -1,37 +0,0 @@ -action = new ResolveClassesInPhpFileAction(); -}); - -it('can find classes', function () { - assertEquals([SomeClass::class,], $this->action->execute( - new SplFileInfo(__DIR__ . '/../FakeClasses/Finder/SomeClass.php', '', '') - )); -}); - -it('can find interfaces', function () { - assertEquals([SomeInterface::class,], $this->action->execute( - new SplFileInfo(__DIR__ . '/../FakeClasses/Finder/SomeInterface.php', '', '') - )); -}); - -it('can find traits', function () { - assertEquals([SomeTrait::class,], $this->action->execute( - new SplFileInfo(__DIR__ . '/../FakeClasses/Finder/SomeTrait.php', '', '') - )); -}); - -it('can find enums', function () { - assertEquals([SomeEnum::class,], $this->action->execute( - new SplFileInfo(__DIR__.'./../FakeClasses/Finder/SomeEnum.php', '', '') - )); -}); diff --git a/tests/Actions/ResolveModuleImportsActionTest.php b/tests/Actions/ResolveModuleImportsActionTest.php new file mode 100644 index 0000000..ee63379 --- /dev/null +++ b/tests/Actions/ResolveModuleImportsActionTest.php @@ -0,0 +1,131 @@ +action = new ResolveModuleImportsAction(); +}); + +it('wont resolve imports when types are in the same module', function () { + $transformedCollection = new TransformedCollection([ + $reference = TransformedFactory::alias('A', new TypeScriptString())->build(), + TransformedFactory::alias('B', new TypeReference($reference->reference), references: [ + $reference, + ])->build(), + ]); + + $location = new Location([], [$reference]); + + expect($this->action->execute($location, $transformedCollection)->isEmpty())->toBe(true); +}); + +it('will import a type from another module', function () { + $transformedCollection = new TransformedCollection([ + $nestedReference = TransformedFactory::alias('Nested', new TypeScriptString(), location: ['parent', 'level', 'nested'])->build(), + $parentReference = TransformedFactory::alias('Parent', new TypeScriptString(), location: ['parent'])->build(), + $deeperParent = TransformedFactory::alias('DeeperParent', new TypeScriptString(), location: ['parent', 'deeper'])->build(), + $rootReference = TransformedFactory::alias('Root', new TypeScriptString(), location: [])->build(), + ]); + + $location = new Location(['parent', 'level'], [ + TransformedFactory::alias('Type', new TypeScriptString(), references: [ + $nestedReference, + $parentReference, + $deeperParent, + $rootReference, + ])->build(), + ]); + + $imports = $this->action->execute($location, $transformedCollection); + + expect($imports->toArray()) + ->toHaveCount(4) + ->each->toBeInstanceOf(ImportLocation::class); + + expect($imports->getTypeScriptNodes())->toEqual([ + new TypeScriptImport('nested', [new ImportName('Nested', $nestedReference->reference)]), + new TypeScriptImport('../', [new ImportName('Parent', $parentReference->reference)]), + new TypeScriptImport('../deeper', [new ImportName('DeeperParent', $deeperParent->reference)]), + new TypeScriptImport('../../', [new ImportName('Root', $rootReference->reference)]), + ]); +}); + +it('wont import the same type twice', function () { + $transformedCollection = new TransformedCollection([ + $nestedReference = TransformedFactory::alias('Nested', new TypeScriptString(), location: ['nested'])->build(), + ]); + + $location = new Location([], [ + TransformedFactory::alias('TypeA', new TypeScriptString(), references: [ + $nestedReference, + ])->build(), + TransformedFactory::alias('TypeB', new TypeScriptString(), references: [ + $nestedReference, + ])->build(), + ]); + + $imports = $this->action->execute($location, $transformedCollection); + + expect($imports->toArray()) + ->toHaveCount(1) + ->each->toBeInstanceOf(ImportLocation::class); + + expect($imports->getTypeScriptNodes())->toEqual([ + new TypeScriptImport('nested', [new ImportName('Nested', $nestedReference->reference)]), + ]); +}); + +it('will alias a reference if it is already in the module', function () { + $transformedCollection = new TransformedCollection([ + $nestedCollection = TransformedFactory::alias('Collection', new TypeScriptString(), location: ['nested'])->build(), + ]); + + $location = new Location([], [ + TransformedFactory::alias('Collection', new TypeScriptString(), references: [ + $nestedCollection, + ])->build(), + ]); + + $imports = $this->action->execute($location, $transformedCollection); + + expect($imports->toArray()) + ->toHaveCount(1) + ->each->toBeInstanceOf(ImportLocation::class); + + expect($imports->getTypeScriptNodes())->toEqual([ + new TypeScriptImport('nested', [new ImportName('Collection', $nestedCollection->reference, 'CollectionImport')]), + ]); +}); + +it('will alias a reference if it is already in the module and already aliased by another import', function () { + $transformedCollection = new TransformedCollection([ + $nestedCollection = TransformedFactory::alias('Collection', new TypeScriptString(), location: ['nested'])->build(), + $otherNestedCollection = TransformedFactory::alias('Collection', new TypeScriptString(), location: ['otherNested'])->build(), + ]); + + $location = new Location([], [ + TransformedFactory::alias('Collection', new TypeScriptString(), references: [ + $nestedCollection, + $otherNestedCollection, + ])->build(), + ]); + + $imports = $this->action->execute($location, $transformedCollection); + + expect($imports->toArray()) + ->toHaveCount(2) + ->each->toBeInstanceOf(ImportLocation::class); + + expect($imports->getTypeScriptNodes())->toEqual([ + new TypeScriptImport('nested', [new ImportName('Collection', $nestedCollection->reference, 'CollectionImport')]), + new TypeScriptImport('otherNested', [new ImportName('Collection', $otherNestedCollection->reference, 'CollectionImport2')]), + ]); +}); diff --git a/tests/Actions/ResolveRelativePathActionTest.php b/tests/Actions/ResolveRelativePathActionTest.php new file mode 100644 index 0000000..0cd5464 --- /dev/null +++ b/tests/Actions/ResolveRelativePathActionTest.php @@ -0,0 +1,53 @@ +execute( + $current, + $requested + ))->toBe($expected); +})->with( + [ + [ + [], + [], + null, + ], + [ + ['a', 'b', 'c'], + ['a', 'b', 'c'], + null, + ], + [ + ['a', 'b', 'c'], + ['a', 'd', 'e'], + '../../d/e', + ], + [ + ['a', 'b', 'c', 'd'], + ['a', 'd', 'e'], + '../../../d/e', + ], + [ + ['a', 'b', 'c'], + ['a', 'd', 'e', 'f'], + '../../d/e/f', + ], + [ + ['a'], + ['b'], + '../b', + ], + [ + ['a', 'b', 'c', 'd'], + ['a', 'b', 'e', 'd'], + '../../e/d', + ], + [ + ['a', 'b'], + ['a'], + '../', + ], + ] +); diff --git a/tests/Actions/ResolveTypesCollectionActionTest.php b/tests/Actions/ResolveTypesCollectionActionTest.php deleted file mode 100644 index 91be832..0000000 --- a/tests/Actions/ResolveTypesCollectionActionTest.php +++ /dev/null @@ -1,126 +0,0 @@ -action = new ResolveTypesCollectionAction( - new Finder(), - TypeScriptTransformerConfig::create() - ->autoDiscoverTypes(__DIR__ . '/../FakeClasses/Enum') - ->transformers([MyclabsEnumTransformer::class]) - ->collectors([DefaultCollector::class]) - ->outputFile('types.d.ts') - ); -}); - -it('will construct the type collection correctly', function () { - $typesCollection = $this->action->execute(); - - assertCount(3, $typesCollection); -}); - -it('will check if auto discover types paths are defined', function () { - $this->expectException(NoAutoDiscoverTypesPathsDefined::class); - - $action = new ResolveTypesCollectionAction( - new Finder(), - TypeScriptTransformerConfig::create() - ); - - $action->execute(); -}); - -it('parses a typescript enum correctly', function () { - $type = $this->action->execute()[TypeScriptEnum::class]; - - assertEquals(new ReflectionClass(new TypeScriptEnum('js')), $type->reflection); - assertEquals('TypeScriptEnum', $type->name); - assertEquals("'js'", $type->transformed); - assertTrue($type->missingSymbols->isEmpty()); -}); - -it('parses a typescript enum with name correctly', function () { - $type = $this->action->execute()[TypeScriptEnumWithName::class]; - - assertEquals(new ReflectionClass(new TypeScriptEnumWithName('js')), $type->reflection); - assertEquals('EnumWithName', $type->name); - assertEquals("'js'", $type->transformed); - assertTrue($type->missingSymbols->isEmpty()); -}); - -it('parses a typescript enum with custom transformer correctly', function () { - $type = $this->action->execute()[TypeScriptEnumWithCustomTransformer::class]; - - assertEquals(new ReflectionClass(new TypeScriptEnumWithCustomTransformer('js')), $type->reflection); - assertEquals('TypeScriptEnumWithCustomTransformer', $type->name); - assertEquals("fake", $type->transformed); - assertTrue($type->missingSymbols->isEmpty()); -}); - -it('can parse multiple directories', function () { - $this->action = new ResolveTypesCollectionAction( - new Finder(), - TypeScriptTransformerConfig::create() - ->autoDiscoverTypes( - __DIR__ . '/../FakeClasses/Enum/', - __DIR__ . '/../FakeClasses/Integration/' - ) - ->transformers([MyclabsEnumTransformer::class, DtoTransformer::class]) - ->collectors([DefaultCollector::class]) - ->outputFile('types.d.ts') - ); - - $types = $this->action->execute(); - - assertCount(9, $types); - - assertArrayHasKey(TypeScriptEnum::class, $types); - assertArrayHasKey(TypeScriptEnumWithCustomTransformer::class, $types); - assertArrayHasKey(TypeScriptEnumWithName::class, $types); - - assertArrayHasKey(Dto::class, $types); - assertArrayHasKey(DtoWithChildren::class, $types); - assertArrayHasKey(Enum::class, $types); - assertArrayHasKey(OtherDto::class, $types); - assertArrayHasKey(OtherDtoCollection::class, $types); - assertArrayHasKey(YetAnotherDto::class, $types); -}); - -it('can add an collector for types', function () { - $this->action = new ResolveTypesCollectionAction( - new Finder(), - TypeScriptTransformerConfig::create() - ->autoDiscoverTypes(__DIR__ . '/../FakeClasses/Enum') - ->collectors([FakeTypeScriptCollector::class]) - ->outputFile('types.d.ts') - ); - - $types = $this->action->execute(); - - assertCount(4, $types); - assertArrayHasKey(RegularEnum::class, $types); - assertArrayHasKey(TypeScriptEnum::class, $types); - assertArrayHasKey(TypeScriptEnumWithCustomTransformer::class, $types); - assertArrayHasKey(TypeScriptEnumWithName::class, $types); -}); diff --git a/tests/Actions/SplitTransformedPerLocationActionTest.php b/tests/Actions/SplitTransformedPerLocationActionTest.php new file mode 100644 index 0000000..b85ad74 --- /dev/null +++ b/tests/Actions/SplitTransformedPerLocationActionTest.php @@ -0,0 +1,38 @@ +build(), + $root1 = TransformedFactory::alias('RootType', new TypeScriptString())->build(), + $level2 = TransformedFactory::alias('Level2Type', new TypeScriptString(), location: ['level1', 'level2'])->build(), + $level12 = TransformedFactory::alias('Level1Type2', new TypeScriptString(), location: ['level1'])->build(), + $root2 = TransformedFactory::alias('RootType2', new TypeScriptString())->build(), + ]); + + $split = (new SplitTransformedPerLocationAction())->execute( + $transformedCollection + ); + + expect($split)->toHaveCount(3); + + expect($split[0]) + ->toBeInstanceOf(Location::class) + ->segments->toBeEmpty() + ->transformed->toEqual([$root1, $root2]); + + expect($split[1]) + ->toBeInstanceOf(Location::class) + ->segments->toBe(['level1']) + ->transformed->toEqual([$level11, $level12]); + + expect($split[2]) + ->toBeInstanceOf(Location::class) + ->segments->toBe(['level1', 'level2']) + ->transformed->toEqual([$level2]); +}); diff --git a/tests/Actions/TransformTypesActionTest.php b/tests/Actions/TransformTypesActionTest.php new file mode 100644 index 0000000..afb4ebd --- /dev/null +++ b/tests/Actions/TransformTypesActionTest.php @@ -0,0 +1,53 @@ +execute( + [ + new EnumTransformer(), + new AllClassTransformer(), + ], + [ + PhpClassNode::fromClassString(StringBackedEnum::class), + PhpClassNode::fromClassString(SimpleClass::class), + ] + ); + + expect($types) + ->toHaveCount(2) + ->each->toBeInstanceOf(Transformed::class); +}); + +it('will not transform untransformable types', function () { + $types = (new TransformTypesAction())->execute( + [ + new EnumTransformer(), + ], + [ + PhpClassNode::fromClassString(SimpleClass::class), + ] + ); + + expect($types)->toBeEmpty(); +}); + +it('can hide classes using an attribute', function () { + $types = (new TransformTypesAction())->execute( + [ + new AllClassTransformer(), + ], + [ + PhpClassNode::fromClassString(HiddenAttributedClass::class), + ] + ); + + expect($types)->toBeEmpty(); +}); diff --git a/tests/Actions/TranspilePhpStanTypeToTypeScriptNodeActionTest.php b/tests/Actions/TranspilePhpStanTypeToTypeScriptNodeActionTest.php new file mode 100644 index 0000000..28d9c82 --- /dev/null +++ b/tests/Actions/TranspilePhpStanTypeToTypeScriptNodeActionTest.php @@ -0,0 +1,269 @@ +execute( + $docTypeResolver->property(new PhpPropertyNode(new ReflectionProperty(PhpDocTypesStub::class, $property)))->type, + new PhpClassNode(new ReflectionClass(PhpDocTypesStub::class)) + ); + + expect($typeScriptNode)->toBeInstanceOf($expectedTypeScriptNode::class); + expect($typeScriptNode)->toEqual($expectedTypeScriptNode); +})->with(function () { + yield [ + 'string', + new TypeScriptString(), + ]; + + yield [ + 'bool', + new TypeScriptBoolean(), + ]; + + yield [ + 'boolean', + new TypeScriptBoolean(), + ]; + + yield [ + 'int', + new TypeScriptNumber(), + ]; + + yield [ + 'integer', + new TypeScriptNumber(), + ]; + + yield [ + 'float', + new TypeScriptNumber(), + ]; + + yield [ + 'double', + new TypeScriptNumber(), + ]; + + yield [ + 'mixed', + new TypeScriptAny(), + ]; + + yield [ + 'void', + new TypeScriptVoid(), + ]; + + yield [ + 'callable', + new TypeScriptFunction(), + ]; + + yield [ + 'false', + new TypeScriptBoolean(), + ]; + + yield [ + 'true', + new TypeScriptBoolean(), + ]; + + yield [ + 'null', + new TypeScriptNull(), + ]; + + yield [ + 'nullable', + new TypeScriptUnion([ + new TypeScriptString(), + new TypeScriptNull(), + ]), + ]; + + yield [ + 'union', + new TypeScriptUnion([ + new TypeScriptNumber(), + new TypeScriptString(), + ]), + ]; + + yield [ + 'intersection', + new TypeScriptIntersection([ + new TypeScriptNumber(), + new TypeScriptString(), + ]), + ]; + + yield [ + 'bnf', + new TypeScriptUnion([ + new TypeScriptIntersection([ + new TypeScriptNumber(), + new TypeScriptString(), + ]), + new TypeScriptNull(), + ]), + ]; + + yield [ + 'self', + new TypeReference(new ClassStringReference(PhpDocTypesStub::class)), + ]; + + yield [ + 'static', + new TypeReference(new ClassStringReference(PhpDocTypesStub::class)), + ]; + + yield [ + 'parent', + new TypeScriptUnknown(), + ]; + + yield [ + 'object', + new TypeScriptObject([]), + ]; + + yield [ + 'objectShape', + new TypeScriptObject([ + new TypeScriptProperty('a', new TypeScriptNumber()), + new TypeScriptProperty('b', new TypeScriptNumber()), + new TypeScriptProperty('c', new TypeScriptNumber()), + new TypeScriptProperty('d', new TypeScriptNumber(), isOptional: true), + ]), + ]; + + yield [ + 'array', + new TypeScriptArray([]), + ]; + + yield [ + 'arrayGeneric', + new TypeScriptArray([new TypeScriptString()]), + ]; + + yield [ + 'arrayGenericWithIntKey', + new TypeScriptArray([new TypeScriptString()]), + ]; + + yield [ + 'arrayGenericWithStringKey', + new TypeScriptGeneric( + new TypeScriptIdentifier('Record'), + [ + new TypeScriptString(), + new TypeScriptString(), + ] + ), + ]; + + yield [ + 'arrayGenericWithArrayKey', + new TypeScriptGeneric( + new TypeScriptIdentifier('Record'), + [ + new TypeScriptUnion([ + new TypeScriptString(), + new TypeScriptNumber(), + ]), + new TypeScriptString(), + ] + ), + ]; + + yield [ + 'typeArray', + new TypeScriptArray([new TypeScriptString()]), + ]; + + yield [ + 'nestedArray', + new TypeScriptGeneric( + new TypeScriptIdentifier('Record'), + [ + new TypeScriptString(), + new TypeScriptArray([new TypeScriptString()]), + ] + ), + ]; + + yield [ + 'arrayShape', + new TypeScriptObject([ + new TypeScriptProperty('a', new TypeScriptNumber()), + new TypeScriptProperty('b', new TypeScriptNumber()), + new TypeScriptProperty('c', new TypeScriptNumber()), + new TypeScriptProperty('d', new TypeScriptNumber(), isOptional: true), + ]), + ]; + + yield [ + 'classString', + new TypeScriptString(), + ]; + + yield [ + 'classStringGeneric', + new TypeScriptString(), + ]; + + yield [ + 'reference', + new TypeReference(new ClassStringReference(Collection::class)), + ]; + + yield [ + 'referenceWithImport', + new TypeReference(new ClassStringReference(Collection::class)), + ]; + + yield [ + 'generic', + new TypeScriptGeneric( + new TypeReference(new ClassStringReference(Collection::class)), + [ + new TypeScriptNumber(), + new TypeScriptString(), + ] + ), + ]; +}); diff --git a/tests/Actions/TranspileReflectionTypeToTypeScriptNodeActionTest.php b/tests/Actions/TranspileReflectionTypeToTypeScriptNodeActionTest.php new file mode 100644 index 0000000..1a93ca0 --- /dev/null +++ b/tests/Actions/TranspileReflectionTypeToTypeScriptNodeActionTest.php @@ -0,0 +1,155 @@ +execute( + (new PhpPropertyNode(new ReflectionProperty(PhpTypesStub::class, $property)))->getType(), + new PhpClassNode(new ReflectionClass(PhpTypesStub::class)) + ); + + expect($typeScriptNode)->toBeInstanceOf($expectedTypeScriptNode::class); + expect($typeScriptNode)->toEqual($expectedTypeScriptNode); +})->with(function () { + yield [ + 'string', + new TypeScriptString(), + ]; + + yield [ + 'bool', + new TypeScriptBoolean(), + ]; + + yield [ + 'int', + new TypeScriptNumber(), + ]; + + yield [ + 'float', + new TypeScriptNumber(), + ]; + + yield [ + 'mixed', + new TypeScriptAny(), + ]; + + yield [ + 'false', + new TypeScriptBoolean(), + ]; + + yield [ + 'true', + new TypeScriptBoolean(), + ]; + + yield [ + 'null', + new TypeScriptNull(), + ]; + + yield [ + 'nullable', + new TypeScriptUnion([ + new TypeScriptString(), + new TypeScriptNull(), + ]), + ]; + + yield [ + 'union', + new TypeScriptUnion([ + new TypeScriptString(), + new TypeScriptNumber(), + ]), + ]; + + yield [ + 'intersection', + new TypeScriptIntersection([ + new TypeReference(new ClassStringReference(Collection::class)), + new TypeReference(new ClassStringReference(Arrayable::class)), + ]), + ]; + + yield [ + 'bnf', + new TypeScriptUnion([ + new TypeScriptIntersection([ + new TypeReference(new ClassStringReference(Collection::class)), + new TypeReference(new ClassStringReference(Arrayable::class)), + ]), + new TypeScriptNull(), + ]), + ]; + + yield [ + 'self', + new TypeReference(new ClassStringReference(PhpTypesStub::class)), + ]; + + // @todo figure out this one + // yield [ + // 'static', + // new TypeReference(new ClassStringReference(PhpTypesStub::class)), + // ]; + + yield [ + 'parent', + new TypeScriptUnknown(), + ]; + + yield [ + 'object', + new TypeScriptObject([]), + ]; + + yield [ + 'array', + new TypeScriptArray([]), + ]; + + yield [ + 'reference', + new TypeReference(new ClassStringReference(Collection::class)), + ]; +}); + +it('can transpile a void return type', function () { + $transpiler = new TranspilePhpTypeNodeToTypeScriptNodeAction(); + + $typeScriptNode = $transpiler->execute( + (new PhpMethodNode(new ReflectionMethod(PhpTypesStub::class, 'voidReturn')))->getReturnType(), + new PhpClassNode(new ReflectionClass(PhpTypesStub::class)) + ); + + expect($typeScriptNode)->toBeInstanceOf(TypeScriptVoid::class); +}); diff --git a/tests/Actions/TranspileTypeToTypeScriptActionTest.php b/tests/Actions/TranspileTypeToTypeScriptActionTest.php deleted file mode 100644 index 55db7f1..0000000 --- a/tests/Actions/TranspileTypeToTypeScriptActionTest.php +++ /dev/null @@ -1,58 +0,0 @@ -missingSymbols = new MissingSymbolsCollection(); - - $this->typeResolver = new TypeResolver(); - - $this->action = new TranspileTypeToTypeScriptAction( - $this->missingSymbols, - 'fake_class' - ); -}); - -it('can resolve types', function (string $input, string $output) { - $resolved = $this->action->execute( - $this->typeResolver->resolve($input), - ); - - assertEquals($output, $resolved); -})->with('types'); - -it('can resolve self referencing types without current class', function () { - $action = new TranspileTypeToTypeScriptAction($this->missingSymbols); - - assertEquals('any', $action->execute(new Self_())); - assertEquals('any', $action->execute(new Static_())); - assertEquals('any', $action->execute(new This())); -}); - -it('can resolve a struct type', function () { - $transformed = $this->action->execute(StructType::fromArray([ - 'a_string' => 'string', - 'a_float' => 'float', - 'a_class' => RegularEnum::class, - 'an_array' => 'int[]', - 'a_self_reference' => '$this', - 'an_object' => [ - 'a_bool' => 'bool', - 'an_int' => 'int', - ], - ])); - - assertMatchesSnapshot($transformed); - assertContains(RegularEnum::class, $this->missingSymbols->all()); - assertContains('fake_class', $this->missingSymbols->all()); -}); diff --git a/tests/Actions/WriteFilesActionTest.php b/tests/Actions/WriteFilesActionTest.php new file mode 100644 index 0000000..9052aa4 --- /dev/null +++ b/tests/Actions/WriteFilesActionTest.php @@ -0,0 +1,30 @@ +temporaryDirectory = TemporaryDirectory::make(); +}); + +it('can write files in a directory', function () { + $fileA = new WriteableFile($this->temporaryDirectory->path('fileA.ts'), 'fileA contents'); + $fileB = new WriteableFile($this->temporaryDirectory->path('fileB.ts'), 'fileB contents'); + + (new WriteFilesAction(TypeScriptTransformerConfigFactory::create()->get()))->execute([$fileA, $fileB]); + + expect(file_get_contents($fileA->path))->toBe('fileA contents'); + expect(file_get_contents($fileB->path))->toBe('fileB contents'); +}); + +it('can write files in a directory with subdirectories', function () { + $fileA = new WriteableFile($this->temporaryDirectory->path('sub/fileA.ts'), 'fileA contents'); + $fileB = new WriteableFile($this->temporaryDirectory->path('sub/sub2/fileB.ts'), 'fileB contents'); + + (new WriteFilesAction(TypeScriptTransformerConfigFactory::create()->get()))->execute([$fileA, $fileB]); + + expect(file_get_contents($fileA->path))->toBe('fileA contents'); + expect(file_get_contents($fileB->path))->toBe('fileB contents'); +}); diff --git a/tests/Actions/__snapshots__/TranspileTypeToTypeScriptActionTest__it_can_resolve_a_struct_type__1.txt b/tests/Actions/__snapshots__/TranspileTypeToTypeScriptActionTest__it_can_resolve_a_struct_type__1.txt deleted file mode 100644 index d15eb55..0000000 --- a/tests/Actions/__snapshots__/TranspileTypeToTypeScriptActionTest__it_can_resolve_a_struct_type__1.txt +++ /dev/null @@ -1 +0,0 @@ -{a_string:string;a_float:number;a_class:{%Spatie\TypeScriptTransformer\Tests\FakeClasses\Enum\RegularEnum%};an_array:Array;a_self_reference:{%fake_class%};an_object:{a_bool:boolean;an_int:number;};} \ No newline at end of file diff --git a/tests/Actions/__snapshots__/files/FormatTypeScriptActionTest__it_can_disable_formatting__1.ts b/tests/Actions/__snapshots__/files/FormatTypeScriptActionTest__it_can_disable_formatting__1.ts deleted file mode 100644 index 0144c8c..0000000 --- a/tests/Actions/__snapshots__/files/FormatTypeScriptActionTest__it_can_disable_formatting__1.ts +++ /dev/null @@ -1 +0,0 @@ -export type Enum='yes'|'no';export type OtherDto={name:string} \ No newline at end of file diff --git a/tests/Actions/__snapshots__/files/FormatTypeScriptActionTest__it_can_format_an_generated_file__1.ts b/tests/Actions/__snapshots__/files/FormatTypeScriptActionTest__it_can_format_an_generated_file__1.ts deleted file mode 100644 index d212a8a..0000000 --- a/tests/Actions/__snapshots__/files/FormatTypeScriptActionTest__it_can_format_an_generated_file__1.ts +++ /dev/null @@ -1,2 +0,0 @@ -export type Enum = "yes" | "no"; -export type OtherDto = { name: string }; diff --git a/tests/Actions/__snapshots__/files/FormatTypeScriptActionTest__it_can_format_an_generated_file__1.ts_failed.ts b/tests/Actions/__snapshots__/files/FormatTypeScriptActionTest__it_can_format_an_generated_file__1.ts_failed.ts deleted file mode 100644 index 440cf18..0000000 --- a/tests/Actions/__snapshots__/files/FormatTypeScriptActionTest__it_can_format_an_generated_file__1.ts_failed.ts +++ /dev/null @@ -1 +0,0 @@ -formatted \ No newline at end of file diff --git a/tests/Actions/__snapshots__/files/PersistTypesCollectionActionTest__it_can_persist_multiple_types_in_one_namespace__1.ts b/tests/Actions/__snapshots__/files/PersistTypesCollectionActionTest__it_can_persist_multiple_types_in_one_namespace__1.ts deleted file mode 100644 index 8156868..0000000 --- a/tests/Actions/__snapshots__/files/PersistTypesCollectionActionTest__it_can_persist_multiple_types_in_one_namespace__1.ts +++ /dev/null @@ -1,6 +0,0 @@ -declare namespace test { -export type Enum = transformed test\Enum; -export type OtherEnum = transformed test\OtherEnum; -} -export type Enum = transformed Enum; -export type OtherEnum = transformed OtherEnum; \ No newline at end of file diff --git a/tests/Actions/__snapshots__/files/PersistTypesCollectionActionTest__it_can_re_save_the_file__1.ts b/tests/Actions/__snapshots__/files/PersistTypesCollectionActionTest__it_can_re_save_the_file__1.ts deleted file mode 100644 index 039e88c..0000000 --- a/tests/Actions/__snapshots__/files/PersistTypesCollectionActionTest__it_can_re_save_the_file__1.ts +++ /dev/null @@ -1,4 +0,0 @@ -declare namespace test { -export type Enum = fake-transformed; -} -export type Enum = fake-transformed; \ No newline at end of file diff --git a/tests/Actions/__snapshots__/files/PersistTypesCollectionActionTest__it_will_persist_the_types__1.ts b/tests/Actions/__snapshots__/files/PersistTypesCollectionActionTest__it_will_persist_the_types__1.ts deleted file mode 100644 index 8fe4cf8..0000000 --- a/tests/Actions/__snapshots__/files/PersistTypesCollectionActionTest__it_will_persist_the_types__1.ts +++ /dev/null @@ -1,7 +0,0 @@ -declare namespace test { -export type Enum = fake-transformed; -} -declare namespace test.test { -export type Enum = fake-transformed; -} -export type Enum = fake-transformed; \ No newline at end of file diff --git a/tests/Attributes/LiteralTypeScriptTypeTest.php b/tests/Attributes/LiteralTypeScriptTypeTest.php new file mode 100644 index 0000000..54da1ec --- /dev/null +++ b/tests/Attributes/LiteralTypeScriptTypeTest.php @@ -0,0 +1,28 @@ +')] + public array $property; + } + + assertMatchesSnapshot(classesToTypeScript([TestSingleLiteralTypeScriptTypeAttribute::class])); +}); + +it('can output an object type', function () { + class TestObjectLiteralTypeScriptTypeAttribute + { + #[LiteralTypeScriptType([ + 'label' => 'string', + 'value' => 'string', + ])] + public array $property; + } + + assertMatchesSnapshot(classesToTypeScript([TestObjectLiteralTypeScriptTypeAttribute::class])); +}); diff --git a/tests/Attributes/TransformAsTypescriptTest.php b/tests/Attributes/TransformAsTypescriptTest.php deleted file mode 100644 index 3ff5919..0000000 --- a/tests/Attributes/TransformAsTypescriptTest.php +++ /dev/null @@ -1,30 +0,0 @@ -getType()); - assertEquals('string|int', (string) $attribute->getType()); -}); - -it('can create the attribute from an array', function () { - $attribute = new TypeScriptType([ - 'a_string' => 'string', - 'a_float' => 'float', - 'a_class' => RegularEnum::class, - 'an_array' => 'int[]', - 'an_object' => [ - 'a_bool' => 'bool', - 'an_int' => 'int', - ], - ]); - - assertInstanceOf(StructType::class, $attribute->getType()); -}); diff --git a/tests/Attributes/TypeScriptTypeTest.php b/tests/Attributes/TypeScriptTypeTest.php new file mode 100644 index 0000000..7ee236a --- /dev/null +++ b/tests/Attributes/TypeScriptTypeTest.php @@ -0,0 +1,35 @@ +')] + public array $property; + } + + assertMatchesSnapshot(classesToTypeScript([ + WriteableFile::class, + TestSingleTypeScriptTypeAttribute::class, + ])); +}); + +it('can output an object type', function () { + class TestObjectTypeScriptTypeAttribute + { + #[TypeScriptType([ + 'name' => 'string', + 'file' => WriteableFile::class, + ])] + public array $property; + } + + assertMatchesSnapshot(classesToTypeScript([ + WriteableFile::class, + TestObjectTypeScriptTypeAttribute::class, + ])); +}); diff --git a/tests/ClassPropertyProcessors/FixArrayLikeStructuresClassPropertyProcessorTest.php b/tests/ClassPropertyProcessors/FixArrayLikeStructuresClassPropertyProcessorTest.php new file mode 100644 index 0000000..c6ff898 --- /dev/null +++ b/tests/ClassPropertyProcessors/FixArrayLikeStructuresClassPropertyProcessorTest.php @@ -0,0 +1,257 @@ + */ + public array $int_key_array; + + /** @var array */ + public array $string_key_array; + + /** @var array */ + public array $array_key_array; + + /** @var array */ + public array $union_key_array; + + /** @var array */ + public array $correct_array; + + /** @var bool[] */ + public array $correct_array_alternative; + + /** @var array */ + public array $missing_types_array; + + public array $no_annotation_array; + }; + + $object = transformSingle($class)->typeScriptNode->type; + + [$propertyNode] = array_values(array_filter( + $object->properties, + fn (TypeScriptProperty $propertyNode) => $propertyNode->name instanceof TypeScriptIdentifier && $propertyNode->name->name === $property + )); + + $propertyNode = (new FixArrayLikeStructuresClassPropertyProcessor())->execute( + phpPropertyNode: new PhpPropertyNode(new ReflectionProperty($class, $property)), + annotation: null, + property: $propertyNode + ); + + expect($propertyNode->type)->toEqual( + $expected + ); +})->with(function () { + yield 'int key array' => [ + 'int_key_array', + new TypeScriptArray([ + new TypeScriptBoolean(), + ]), + ]; + + yield 'string key array' => [ + 'string_key_array', + new TypeScriptGeneric( + new TypeScriptIdentifier('Record'), + [ + new TypeScriptString(), + new TypeScriptBoolean(), + ], + ), + ]; + + yield 'array key array' => [ + 'array_key_array', + new TypeScriptGeneric( + new TypeScriptIdentifier('Record'), + [ + new TypeScriptUnion([ + new TypeScriptString(), + new TypeScriptNumber(), + ]), + new TypeScriptBoolean(), + ], + ), + ]; + + yield 'union key array' => [ + 'union_key_array', + new TypeScriptGeneric( + new TypeScriptIdentifier('Record'), + [ + new TypeScriptUnion([ + new TypeScriptString(), + new TypeScriptNumber(), + ]), + new TypeScriptBoolean(), + ], + ), + ]; + + yield 'correct array' => [ + 'correct_array', + new TypeScriptArray([ + new TypeScriptBoolean(), + ]), + ]; + + yield 'correct array alternative' => [ + 'correct_array_alternative', + new TypeScriptArray([ + new TypeScriptBoolean(), + ]), + ]; + + yield 'missing types array' => [ + 'missing_types_array', + new TypeScriptArray([]), + ]; + + yield 'no annotation array' => [ + 'no_annotation_array', + new TypeScriptArray([]), + ]; +}); + +it('replaces array like classes', function ( + string $property, + TypeScriptNode $expected, +) { + $class = new class () { + /** @var Collection */ + public Collection $int_key_collection; + + /** @var Collection */ + public Collection $string_key_collection; + + /** @var Collection */ + public Collection $array_key_collection; + + /** @var Collection */ + public Collection $union_key_collection; + + /** @var Collection */ + public Collection $missing_key_collection; + + /** @var Collection */ + public Collection $missing_types_collection; + + /** @var Collection */ + public Collection $too_much_types_collection; + + public Collection $no_annotation_collection; + }; + + $object = transformSingle($class)->typeScriptNode->type; + + [$propertyNode] = array_values(array_filter( + $object->properties, + fn (TypeScriptProperty $propertyNode) => $propertyNode->name instanceof TypeScriptIdentifier && $propertyNode->name->name === $property + )); + + $propertyNode = (new FixArrayLikeStructuresClassPropertyProcessor( + arrayLikeClassesToReplace: [Collection::class], + ))->execute( + phpPropertyNode: new PhpPropertyNode(new ReflectionProperty($class, $property)), + annotation: null, + property: $propertyNode + ); + + expect($propertyNode->type)->toEqual( + $expected + ); +})->with(function () { + yield 'int key collection' => [ + 'int_key_collection', + new TypeScriptArray([ + new TypeScriptBoolean(), + ]), + ]; + + yield 'string key collection' => [ + 'string_key_collection', + new TypeScriptGeneric( + new TypeScriptIdentifier('Record'), + [ + new TypeScriptString(), + new TypeScriptBoolean(), + ], + ), + ]; + + yield 'array key collection' => [ + 'array_key_collection', + new TypeScriptGeneric( + new TypeScriptIdentifier('Record'), + [ + new TypeScriptUnion([ + new TypeScriptString(), + new TypeScriptNumber(), + ]), + new TypeScriptBoolean(), + ], + ), + ]; + + yield 'union key collection' => [ + 'union_key_collection', + new TypeScriptGeneric( + new TypeScriptIdentifier('Record'), + [ + new TypeScriptUnion([ + new TypeScriptString(), + new TypeScriptNumber(), + ]), + new TypeScriptBoolean(), + ], + ), + ]; + + yield 'missing key collection' => [ + 'missing_key_collection', + new TypeScriptArray([ + new TypeScriptBoolean(), + ]), + ]; + + yield 'missing types collection' => [ + 'missing_types_collection', + new TypeReference(new ClassStringReference(Collection::class)), + ]; + + yield 'too much types collection' => [ + 'too_much_types_collection', + new TypeScriptGeneric( + new TypeReference(new ClassStringReference(Collection::class)), + [ + new TypeScriptString(), + new TypeScriptNumber(), + new TypeScriptBoolean(), + ], + ), + ]; + + yield 'no annotation collection' => [ + 'no_annotation_collection', + new TypeReference(new ClassStringReference(Collection::class)), + ]; +}); diff --git a/tests/ClassReaderTest.php b/tests/ClassReaderTest.php deleted file mode 100644 index 150d117..0000000 --- a/tests/ClassReaderTest.php +++ /dev/null @@ -1,78 +0,0 @@ -reader = new ClassReader(); -}); - -it('non transformable case', function () { - $fake = new class { - }; - - ['transformable' => $transformable] = $this->reader->forClass( - new ReflectionClass($fake) - ); - - assertFalse($transformable); -}); - -it('default case', function () { - /** - * @typescript - */ - $fake = new class { - }; - - ['name' => $name] = $this->reader->forClass( - new ReflectionClass($fake) - ); - - assertStringContainsString('class@anonymous', $name); -}); - -it('default file case', function () { - /** - * @typescript OtherEnum - */ - $fake = new class { - }; - - ['name' => $name] = $this->reader->forClass( - new ReflectionClass($fake) - ); - - assertEquals('OtherEnum', $name); -}); - -it('will resolve the transformer', function () { - /** - * @typescript-transformer \Spatie\TypeScriptTransformer\Transformers\MyclabsEnumTransformer - */ - $fake = new class { - }; - - assertEquals('\\' . MyclabsEnumTransformer::class, $this->reader->forClass( - new ReflectionClass($fake) - )['transformer']); -}); - -it('inline case', function () { - /** - * @typescript - * @typescript-inline - */ - $fake = new class { - }; - - ['inline' => $inline] = $this->reader->forClass( - new ReflectionClass($fake) - ); - - assertTrue($inline); -}); diff --git a/tests/Collections/TransformedCollectionTest.php b/tests/Collections/TransformedCollectionTest.php new file mode 100644 index 0000000..0aaa720 --- /dev/null +++ b/tests/Collections/TransformedCollectionTest.php @@ -0,0 +1,172 @@ +toHaveCount(1); +}); + +it('can add transformed items to the collection', function () { + $collection = new TransformedCollection(); + + $collection->add( + $initialTransformed = transformSingle(SimpleClass::class), + ); + + expect($collection)->toHaveCount(1); +}); + +it('can get a transformed item by reference', function () { + $collection = new TransformedCollection([ + $classTransformed = transformSingle(SimpleClass::class), + $manualTransformed = new Transformed( + new TypeScriptString(), + new CustomReference('vendor', 'package'), + [], + ), + ]); + + expect($collection->has(new ClassStringReference(SimpleClass::class)))->toBeTrue(); + expect($collection->get(new ClassStringReference(SimpleClass::class)))->toBe($classTransformed); + expect($collection->has(new CustomReference('vendor', 'package')))->toBeTrue(); + expect($collection->get(new CustomReference('vendor', 'package')))->toBe($manualTransformed); +}); + +it('can loop over items in the collection', function () { + $collection = new TransformedCollection([ + $a = transformSingle(SimpleClass::class), + $b = transformSingle(TypeScriptAttributedClass::class), + ]); + + $found = []; + + foreach ($collection as $transformed) { + $found[] = $transformed; + } + + expect($found)->toBe([$a, $b]); +}); + +it('can loop over only changed items in the collection', function () { + $collection = new TransformedCollection([ + $a = transformSingle(SimpleClass::class), + $b = transformSingle(TypeScriptAttributedClass::class), + ]); + + $a->changed = true; + $b->changed = false; + + $found = []; + + foreach ($collection->onlyChanged() as $transformed) { + $found[] = $transformed; + } + + expect($found)->toBe([$a]); +}); + +it('all items added to the collection are marked as changed', function () { + new TransformedCollection([ + $a = transformSingle(SimpleClass::class), + $b = transformSingle(TypeScriptAttributedClass::class), + ]); + + expect($a->changed)->toBeTrue(); + expect($b->changed)->toBeTrue(); +}); + +it('can find transformed items by file path', function () { + $collection = new TransformedCollection([ + $transformed = transformSingle(SimpleClass::class), + ]); + + $path = __DIR__.'/../Fakes/TypesToProvide/SimpleClass.php'; + + expect($collection->findTransformedByFile($path))->toBe($transformed); +}); + +it('can find transformed items by directory path', function () { + $collection = new TransformedCollection([ + $a = transformSingle(SimpleClass::class), + $b = transformSingle(TypeScriptAttributedClass::class), + $c = transformSingle(CircularA::class), + ]); + + $path = __DIR__.'/../Fakes/TypesToProvide'; + + $found = []; + + foreach ($collection->findTransformedByDirectory($path) as $transformed) { + $found[] = $transformed; + } + + expect($found)->toBe([$a, $b]); +}); + +it('can check if any items in the collection have changed', function () { + $collection = new TransformedCollection([ + $a = transformSingle(SimpleClass::class), + $b = transformSingle(TypeScriptAttributedClass::class), + ]); + + $a->changed = false; + $b->changed = false; + + expect($collection->hasChanges())->toBeFalse(); + + $a->changed = true; + + expect($collection->hasChanges())->toBeTrue(); +}); + +it('can remove a transformed item by reference', function () { + $collection = new TransformedCollection([ + transformSingle(SimpleClass::class), + ]); + + $collection->remove(new ClassStringReference(SimpleClass::class)); + + expect($collection->has(new ClassStringReference(SimpleClass::class)))->toBeFalse(); +}); + +it('can remove a transformed item by reference and update references', function () { + $collection = TypeScriptTransformer::create( + TypeScriptTransformerConfigFactory::create() + ->transformer(new AllClassTransformer()) + ->watchDirectories(__DIR__.'/../Fakes/Circular') + )->resolveTransformedCollection(); + + foreach ($collection as $transformed) { + $transformed->changed = false; + } + + $collection->remove($referenceA = new ClassStringReference(CircularA::class)); + + expect($collection)->toHaveCount(1); + + $transformedB = $collection->get($referenceB = new ClassStringReference(CircularB::class)); + + expect($transformedB->changed)->toBeTrue(); + expect($transformedB->missingReferences)->toHaveCount(1); + expect($transformedB->missingReferences)->toHaveKey($referenceA->getKey()); + expect($transformedB->missingReferences[$referenceA->getKey()]) + ->toBeArray() + ->each() + ->toBeInstanceOf(TypeReference::class); +}); diff --git a/tests/Collectors/DefaultCollectorTest.php b/tests/Collectors/DefaultCollectorTest.php deleted file mode 100644 index 50b4f44..0000000 --- a/tests/Collectors/DefaultCollectorTest.php +++ /dev/null @@ -1,204 +0,0 @@ -config = TypeScriptTransformerConfig::create()->transformers([ - MyclabsEnumTransformer::class, - ]); - - $this->collector = new DefaultCollector($this->config); -}); - -it('will not collect non annotated classes', function () { - $class = new class('a') extends Enum { - const A = 'a'; - }; - - $reflection = new ReflectionClass( - $class - ); - - assertNull($this->collector->getTransformedType($reflection)); -}); - -it('will collect annotated classes', function () { - /** @typescript */ - $class = new class('a') extends Enum { - const A = 'a'; - }; - - $reflection = new ReflectionClass( - $class - ); - - $transformedType = $this->collector->getTransformedType($reflection); - - assertNotNull($transformedType); - assertEquals( - "'a' | 'yes' | 'no'", - $transformedType->transformed, - ); -}); - -it('will collect annotated classes and use the given name', function () { - /** @typescript EnumTransformed */ - $class = new class('a') extends Enum { - const A = 'a'; - }; - - $reflection = new ReflectionClass( - $class - ); - - $transformedType = $this->collector->getTransformedType($reflection); - - assertNotNull($transformedType); - assertEquals('EnumTransformed', $transformedType->name); - assertEquals( - "'a' | 'yes' | 'no'", - $transformedType->transformed, - ); -}); - -it('will read overwritten transformers', function () { - /** - * @typescript DtoTransformed - * @typescript-transformer \Spatie\TypeScriptTransformer\Transformers\DtoTransformer - */ - $class = new class('a') extends Enum { - const A = 'a'; - - public int $an_integer; - }; - - $reflection = new ReflectionClass( - $class - ); - - $transformedType = $this->collector->getTransformedType($reflection); - - assertNotNull($transformedType); - assertEquals('DtoTransformed', $transformedType->name); - assertEquals( - '{'.PHP_EOL.'an_integer: number;'.PHP_EOL.'}', - $transformedType->transformed, - ); -}); - -it('will throw an exception if a transformer is not found', function () { - /** @typescript */ - $class = new class { - }; - - $reflection = new ReflectionClass( - $class - ); - - $this->collector->getTransformedType($reflection); -})->throws(TransformerNotFound::class); - -it('will collect classes with attributes', function () { - $reflection = new ReflectionClass(WithTypeScriptAttribute::class); - - $transformedType = $this->collector->getTransformedType($reflection); - - assertNotNull($transformedType); - assertEquals('WithTypeScriptAttribute', $transformedType->name); - assertEquals( - "'a' | 'b'", - $transformedType->transformed, - ); -}); - -it('will collect attribute overwritten transformers', function () { - $reflection = new ReflectionClass(WithTypeScriptTransformerAttribute::class); - - $transformedType = $this->collector->getTransformedType($reflection); - - assertNotNull($transformedType); - assertEquals('WithTypeScriptTransformerAttribute', $transformedType->name); - assertEquals( - '{'.PHP_EOL.'an_int: number;'.PHP_EOL.'}', - $transformedType->transformed, - ); -}); - -it('will collect classes with already transformed attributes', function () { - $reflection = new ReflectionClass(WithAlreadyTransformedAttributeAttribute::class); - - $transformedType = $this->collector->getTransformedType($reflection); - - assertNotNull($transformedType); - assertEquals( - '{an_int:number;a_bool:boolean;}', - $transformedType->transformed, - ); -}); - -it('can inline collected classes with annotations', function () { - $reflection = new ReflectionClass(WithTypeScriptInlineAttribute::class); - - $transformedType = $this->collector->getTransformedType($reflection); - - assertNotNull($transformedType); - assertTrue($transformedType->isInline); -}); - -it('can inline collected classes with attributes', function () { - /** - * @typescript - * @typescript-inline - */ - $class = new class('a') extends Enum { - const A = 'a'; - }; - - $transformedType = $this->collector->getTransformedType(new ReflectionClass($class)); - - assertNotNull($transformedType); - assertTrue($transformedType->isInline); -}); - -it('will will throw an exception with non existing transformers', function () { - $this->expectException(InvalidTransformerGiven::class); - $this->expectExceptionMessage("does not exist!"); - - /** - * @typescript DtoTransformed - * @typescript-transformer FAKE - */ - $class = new class('a') extends Enum { - const A = 'a'; - - public int $an_integer; - }; - - $this->collector->getTransformedType(new ReflectionClass($class)); -}); - -it('will will throw an exception with class that does not implement transformer', function () { - $this->expectException(InvalidTransformerGiven::class); - $this->expectExceptionMessage("does not implement the Transformer interface!"); - - /** - * @typescript-transformer \Spatie\TypeScriptTransformer\Structures\TransformedType - */ - $class = new class { - }; - - $this->collector->getTransformedType(new ReflectionClass($class)); -}); diff --git a/tests/Collectors/EnumCollectorTest.php b/tests/Collectors/EnumCollectorTest.php deleted file mode 100644 index 223245b..0000000 --- a/tests/Collectors/EnumCollectorTest.php +++ /dev/null @@ -1,26 +0,0 @@ -transformers([ - EnumTransformer::class, - ]) - ); - - $reflection = new ReflectionClass(BackedEnumWithoutAnnotation::class); - $transformedType = $collector->getTransformedType($reflection); - - assertNotNull($transformedType); - assertEquals( - "'foo' | 'bar'", - $transformedType->transformed, - ); -})->skip(version_compare(PHP_VERSION, '8.1', '<'), 'Enums are a PHP 8.1+ feature.'); diff --git a/tests/Datasets/ReflectionClasses.php b/tests/Datasets/ReflectionClasses.php deleted file mode 100644 index c272a5a..0000000 --- a/tests/Datasets/ReflectionClasses.php +++ /dev/null @@ -1,102 +0,0 @@ - [ - 'reflection' => $reflection = FakeReflectionClass::create(), - 'transformable' => false, - 'inline' => false, - 'name' => $reflection->getName(), - 'transformer' => null, - 'type' => null, - ]; - - yield '@typescript annotation' => [ - 'reflection' => $reflection = FakeReflectionClass::create()->withDocComment('/** @typescript */'), - 'transformable' => true, - 'inline' => false, - 'name' => $reflection->getName(), - 'transformer' => null, - 'type' => null, - ]; - - yield '@typescript annotation with name' => [ - 'reflection' => $reflection = FakeReflectionClass::create()->withDocComment('/** @typescript YoloClass */'), - 'transformable' => true, - 'inline' => false, - 'name' => 'YoloClass', - 'transformer' => null, - 'type' => null, - ]; - - yield '@typescript annotation with transformer' => [ - 'reflection' => $reflection = FakeReflectionClass::create()->withDocComment('/** @typescript @typescript-transformer FakeTransformer */'), - 'transformable' => true, - 'inline' => false, - 'name' => $reflection->getName(), - 'transformer' => 'FakeTransformer', - 'type' => null, - ]; - - yield '@typescript annotation with inline' => [ - 'reflection' => $reflection = FakeReflectionClass::create()->withDocComment('/** @typescript @typescript-inline */'), - 'transformable' => true, - 'inline' => true, - 'name' => $reflection->getName(), - 'transformer' => null, - 'type' => null, - ]; - - yield 'TypeScript attribute' => [ - 'reflection' => new ReflectionClass(WithTypeScriptAttribute::class), - 'transformable' => true, - 'inline' => false, - 'name' => 'WithTypeScriptAttribute', - 'transformer' => null, - 'type' => null, - ]; - - yield 'TypeScript attribute with name' => [ - 'reflection' => new ReflectionClass(WithTypeScriptNamedAttribute::class), - 'transformable' => true, - 'inline' => false, - 'name' => 'YoloClass', - 'transformer' => null, - 'type' => null, - ]; - - yield 'TypeScript inline attribute' => [ - 'reflection' => new ReflectionClass(WithTypeScriptInlineAttribute::class), - 'transformable' => true, - 'inline' => true, - 'name' => 'WithTypeScriptInlineAttribute', - 'transformer' => null, - 'type' => null, - ]; - - yield 'TypeScript transformer attribute' => [ - 'reflection' => new ReflectionClass(WithTypeScriptTransformerAttribute::class), - 'transformable' => true, - 'inline' => false, - 'name' => 'WithTypeScriptTransformerAttribute', - 'transformer' => DtoTransformer::class, - 'type' => null, - ]; - - yield 'TypeScript already transformed attribute' => [ - 'reflection' => new ReflectionClass(WithAlreadyTransformedAttributeAttribute::class), - 'transformable' => true, - 'inline' => false, - 'name' => 'WithAlreadyTransformedAttributeAttribute', - 'transformer' => null, - 'type' => StructType::fromArray(['an_int' => 'int', 'a_bool' => 'bool']), - ]; -}); diff --git a/tests/Datasets/Types.php b/tests/Datasets/Types.php deleted file mode 100644 index b87f898..0000000 --- a/tests/Datasets/Types.php +++ /dev/null @@ -1,94 +0,0 @@ -'], - - // Arrays - ['string[]', 'Array'], - ['string[]|Array', 'Array'], - ['(string|integer)[]', 'Array'], - ['Array', 'Array'], - - // Objects - ['Array', '{ [key: number]: string }'], - ['Array', '{ [key: string]: number }'], - ['Array', '{ [key: string]: number | boolean }'], - - // Null - ['?string', 'string | null'], - ['?string[]', 'Array | null'], - - // Objects - [Enum::class, '{%' . Enum::class . '%}'], - [Enum::class . '[]', 'Array<{%' . Enum::class . '%}>'], - - // Simple - ['string', 'string'], - ['boolean', 'boolean'], - ['integer', 'number'], - ['double', 'number'], - ['float', 'number'], - ['class-string<' . Enum::class . '>', 'string'], - ['null', 'null'], - ['object', 'object'], - ['array', 'Array'], - - // references - ['self', '{%fake_class%}'], - ['static', '{%fake_class%}'], - ['$this', '{%fake_class%}'], - - // Scalar - ['scalar', 'string|number|boolean'], - - // Mixed - ['mixed', 'any'], - - // Collections - ['Collection', 'Array'], -]); - -dataset('docblock_types', [ - ['int', 'int'], - ['bool', 'bool'], - ['string', 'string'], - ['float', 'float'], - ['mixed', 'mixed'], - ['array', 'array'], - - ['bool|int', 'bool|int'], - ['?int', '?int'], - ['int[]', 'int[]'], -]); - -dataset('reflection_types', [ - ['int', true, 'int'], - ['bool', true, 'bool'], - ['mixed', true, 'mixed'], - ['string', true, 'string'], - ['float', true, 'float'], - ['array', true, 'array'], - - [Enum::class, false, '\\' . Enum::class], -]); - -dataset('ignored_types', [ - ['int', 'int', 'int'], - ['int|array', 'array', 'int|array'], - ['int[]', 'array', 'int[]'], - ['?int[]', 'array', '?int[]'], -]); - -dataset('nullified_types', [ - ['', '?int'], - ['?int', '?int'], - ['int', '?int'], - ['array|int', 'array|int|null'], - ['array|int|null', 'array|int|null'], - ['mixed', 'mixed'], -]); diff --git a/tests/Factories/TransformedFactory.php b/tests/Factories/TransformedFactory.php new file mode 100644 index 0000000..609b015 --- /dev/null +++ b/tests/Factories/TransformedFactory.php @@ -0,0 +1,76 @@ + $references + * @param array $referencedBy + */ + public function __construct( + public TypeScriptNode $typeScriptNode, + public ?Reference $reference = null, + public ?array $location = null, + public ?bool $export = null, + public ?array $references = null, + public ?array $referencedBy = null, + ) { + } + + public static function alias( + string $name, + TypeScriptNode $typeScriptNode, + ?Reference $reference = null, + ?array $location = null, + bool $export = true, + ?array $references = null, + ?array $referencedBy = null, + ): TransformedFactory { + $reference = $reference ?? new CustomReference( + 'factory_alias', + ($location !== null ? implode('.', $location) : '').Str::slug($name) + ); + + return new self( + typeScriptNode: new TypeScriptAlias(new TypeScriptIdentifier($name), $typeScriptNode), + reference: $reference, + location: $location, + export: $export, + references: $references, + referencedBy: $referencedBy + ); + } + + public function build(): Transformed + { + $reference = $this->reference ?? new CustomReference('factory', Str::random(6)); + $location = $this->location ?? []; + $export = $this->export ?? true; + + $transformed = new Transformed( + typeScriptNode: $this->typeScriptNode, + reference: $reference, + location: $location, + export: $export, + ); + + foreach ($this->references ?? [] as $reference) { + $transformed->references[$reference->reference->getKey()] = []; + } + + foreach ($this->referencedBy ?? [] as $reference) { + $transformed->referencedBy[] = $reference->reference->getKey(); + } + + return $transformed; + } +} diff --git a/tests/FakeClasses/Annotations/FakeAnnotationsClass.php b/tests/FakeClasses/Annotations/FakeAnnotationsClass.php deleted file mode 100644 index 97b8657..0000000 --- a/tests/FakeClasses/Annotations/FakeAnnotationsClass.php +++ /dev/null @@ -1,17 +0,0 @@ - 'int', 'a_bool' => 'bool'])] -class WithAlreadyTransformedAttributeAttribute -{ -} diff --git a/tests/FakeClasses/Attributes/WithTypeScriptAttribute.php b/tests/FakeClasses/Attributes/WithTypeScriptAttribute.php deleted file mode 100644 index 6becfea..0000000 --- a/tests/FakeClasses/Attributes/WithTypeScriptAttribute.php +++ /dev/null @@ -1,13 +0,0 @@ - */ - public $mixed_with_array; - - /** @var array */ - public $array_with_null; - - public Enum $enum; - - public RegularEnum $non_typescripted_type; - - public OtherDto $other_dto; - - /** @var \Spatie\TypeScriptTransformer\Tests\FakeClasses\Integration\OtherDto[] */ - public array $other_dto_array; - - public OtherDtoCollection $other_dto_collection; - - public DtoWithChildren $dto_with_children; - - public YetAnotherDto $another_namespace_dto; - - /** @var string|int */ - public ?string $nullable_string; - - public DateTime $reflection_replaced_default_type; - - /** @var \DateTime */ - public $docblock_replaced_default_type; - - /** @var \DateTime[] */ - public array $array_replaced_default_type; - - /** @var array */ - public array $array_as_object; -} diff --git a/tests/FakeClasses/Integration/DtoWithChildren.php b/tests/FakeClasses/Integration/DtoWithChildren.php deleted file mode 100644 index 072b67e..0000000 --- a/tests/FakeClasses/Integration/DtoWithChildren.php +++ /dev/null @@ -1,16 +0,0 @@ - 'Draft', - 'published' => 'Published', - 'archived' => 'Archived', - ]; - } -} diff --git a/tests/FakeClasses/StringBackedEnum.php b/tests/FakeClasses/StringBackedEnum.php deleted file mode 100644 index 865c2b2..0000000 --- a/tests/FakeClasses/StringBackedEnum.php +++ /dev/null @@ -1,12 +0,0 @@ -withNamespace = $namespace; - - return $this; - } - - public function withoutNamespace(): self - { - $this->withNamespace = ''; - - return $this; - } - - public function getNamespaceName(): string - { - return $this->withNamespace ?: parent::getNamespaceName(); - } - - public function getName(): string - { - $name = $this->entityName ?? parent::getShortName(); - - return empty($this->getNamespaceName()) - ? $name - : "{$this->getNamespaceName()}\\{$name}"; - } -} diff --git a/tests/Fakes/FakeReflectionMethod.php b/tests/Fakes/FakeReflectionMethod.php deleted file mode 100644 index aa4369b..0000000 --- a/tests/Fakes/FakeReflectionMethod.php +++ /dev/null @@ -1,10 +0,0 @@ -type = $type; - - return $this; - } - - public function withIsBuiltIn(bool $isBuiltIn = true): self - { - $this->isBuiltIn = $isBuiltIn; - - return $this; - } - - public function withAllowsNull(bool $allowsNull = true): self - { - $this->allowsNull = $allowsNull; - - return $this; - } - - public function getName(): string - { - return $this->type; - } - - public function isBuiltin(): bool - { - return $this->isBuiltIn; - } - - public function allowsNull(): bool - { - return $this->allowsNull; - } - - public function __toString() - { - return $this->getName(); - } -} diff --git a/tests/Fakes/FakeReflectionUnionType.php b/tests/Fakes/FakeReflectionUnionType.php deleted file mode 100644 index 0db43f0..0000000 --- a/tests/Fakes/FakeReflectionUnionType.php +++ /dev/null @@ -1,43 +0,0 @@ -types = array_merge($this->types, $type); - - return $this; - } - - public function getTypes(): array - { - return $this->types; - } - - public function allowsNull(): bool - { - foreach ($this->types as $type) { - if ($type->allowsNull()) { - return true; - } - } - - return false; - } -} diff --git a/tests/Fakes/FakeTransformedType.php b/tests/Fakes/FakeTransformedType.php deleted file mode 100644 index 80fc4f4..0000000 --- a/tests/Fakes/FakeTransformedType.php +++ /dev/null @@ -1,78 +0,0 @@ -withName($name), - $name, - 'fake-transformed', - new MissingSymbolsCollection(), - false - ); - } - - public function withReflection(ReflectionClass $reflection): self - { - $this->reflection = $reflection; - - return $this; - } - - public function withNamespace(string $namespace): self - { - $this->reflection->withNamespace($namespace); - - return $this; - } - - public function withoutNamespace(): self - { - $this->reflection->withoutNamespace(); - - return $this; - } - - public function withTransformed(string $transformed): self - { - $this->transformed = $transformed; - - return $this; - } - - public function withMissingSymbols(array $missingSymbols): self - { - foreach ($missingSymbols as $missingSymbol) { - $this->missingSymbols->add($missingSymbol); - } - - return $this; - } - - public function isInline(bool $isInline = true): self - { - $this->isInline = $isInline; - - return $this; - } -} diff --git a/tests/Fakes/FakeTypeScriptCollector.php b/tests/Fakes/FakeTypeScriptCollector.php deleted file mode 100644 index 1e64687..0000000 --- a/tests/Fakes/FakeTypeScriptCollector.php +++ /dev/null @@ -1,28 +0,0 @@ -getName(), Enum::class); - } - - public function getTransformedType(ReflectionClass $class): TransformedType - { - return new TransformedType( - $class, - $class->getShortName(), - 'fake-collected-class', - new MissingSymbolsCollection(), - false - ); - } -} diff --git a/tests/Fakes/FakeTypeScriptTransformer.php b/tests/Fakes/FakeTypeScriptTransformer.php deleted file mode 100644 index 38093ba..0000000 --- a/tests/Fakes/FakeTypeScriptTransformer.php +++ /dev/null @@ -1,30 +0,0 @@ -isSubclassOf(Enum::class); - } - - public function transform(ReflectionClass $class, string $name): TransformedType - { - return FakeTransformedType::fake($name) - ->withReflection($class) - ->withTransformed($this->transformed); - } -} diff --git a/tests/Fakes/FakedReflection.php b/tests/Fakes/FakedReflection.php deleted file mode 100644 index d0eedf8..0000000 --- a/tests/Fakes/FakedReflection.php +++ /dev/null @@ -1,77 +0,0 @@ -docComment = $docComment; - - return $this; - } - - public function withName(string $name): self - { - $this->entityName = $name; - - return $this; - } - - public function withType(FakeReflectionType | ReflectionType $type): self - { - $this->type = $type; - - return $this; - } - - public function getDocComment(): string|false - { - if ($this->docComment === '') { - return false; - } - - return $this->docComment; - } - - public function getName(): string - { - if ($this->entityName === null) { - return ''; - } - - return $this->entityName; - } - - public function getType(): null | ReflectionType | ReflectionUnionType | FakeReflectionType - { - if ($this->type === null) { - return null; - } - - return $this->type; - } - - public function getAttributes(?string $name = null, int $flags = 0): array - { - return []; - } -} diff --git a/tests/Fakes/PropertyTypes/PhpDocTypesStub.php b/tests/Fakes/PropertyTypes/PhpDocTypesStub.php new file mode 100644 index 0000000..40cf216 --- /dev/null +++ b/tests/Fakes/PropertyTypes/PhpDocTypesStub.php @@ -0,0 +1,114 @@ + */ + public $arrayGeneric; + + /** @var array */ + public $arrayGenericWithIntKey; + + /** @var array */ + public $arrayGenericWithStringKey; + + /** @var array */ + public $arrayGenericWithArrayKey; + + /** @var string[] */ + public $typeArray; + + /** @var array> */ + public $nestedArray; + + /** @var array{a: int, 'b': int, "c": int, d?: int} */ + public $arrayShape; + + /** @var class-string */ + public $classString; + + /** @var class-string */ + public $classStringGeneric; + + /** @var \Illuminate\Support\Collection */ + public $reference; + + /** @var Collection */ + public $referenceWithImport; + + /** @var Collection */ + public $generic; +} diff --git a/tests/Fakes/PropertyTypes/PhpTypesStub.php b/tests/Fakes/PropertyTypes/PhpTypesStub.php new file mode 100644 index 0000000..b15d115 --- /dev/null +++ b/tests/Fakes/PropertyTypes/PhpTypesStub.php @@ -0,0 +1,51 @@ + + */ + public function withAnnotatedReturnType(): array; + + public function withParameters(string $param1, int $param2): void; + + public function withOptionalParameters(string $param1, int $param2 = 5): void; + + /** + * @param array $param1 + * @param array $param2 + */ + public function withAnnotatedParameters(array $param1, array $param2): void; +} diff --git a/tests/Fakes/TypesToProvide/StringBackedEnum.php b/tests/Fakes/TypesToProvide/StringBackedEnum.php new file mode 100644 index 0000000..6373412 --- /dev/null +++ b/tests/Fakes/TypesToProvide/StringBackedEnum.php @@ -0,0 +1,11 @@ +temporaryDirectory = TemporaryDirectory::make(); +}); + +it('can handle the integration test with a flat file', function () { + $config = TypeScriptTransformerConfigFactory::create() + ->transformer(new EnumTransformer()) + ->transformer(new AllClassTransformer()) + ->watchDirectories(__DIR__ . '/Fakes/Integration') + ->replaceType(DateTime::class, 'string') + ->writer(new FlatWriter($this->temporaryDirectory->path('flat.d.ts'))); + + TypeScriptTransformer::create($config)->execute(); + + assertMatchesFileSnapshot($this->temporaryDirectory->path('flat.d.ts')); +}); + +it('can handle the integration test with a namespaced file', function () { + $config = TypeScriptTransformerConfigFactory::create() + ->transformer(new EnumTransformer()) + ->transformer(new AllClassTransformer()) + ->watchDirectories(__DIR__ . '/Fakes/Integration') + ->replaceType(DateTime::class, 'string') + ->writer(new NamespaceWriter($this->temporaryDirectory->path('flat.d.ts'))); + + TypeScriptTransformer::create($config)->execute(); + + assertMatchesFileSnapshot($this->temporaryDirectory->path('flat.d.ts')); +}); + +it('can handle the integration test with a module structure', function () { + $config = TypeScriptTransformerConfigFactory::create() + ->transformer(new EnumTransformer()) + ->transformer(new AllClassTransformer()) + ->watchDirectories(__DIR__ . '/Fakes/Integration') + ->replaceType(DateTime::class, 'string') + ->writer(new ModuleWriter($this->temporaryDirectory->path())); + + TypeScriptTransformer::create($config)->execute(); + + assertMatchesFileSnapshot($this->temporaryDirectory->path('Spatie/TypeScriptTransformer/Tests/Fakes/Integration/index.ts')); + assertMatchesFileSnapshot($this->temporaryDirectory->path('Spatie/TypeScriptTransformer/Tests/Fakes/Integration/Level/index.ts')); +}); diff --git a/tests/IntegrationTest.php b/tests/IntegrationTest.php deleted file mode 100644 index 74bbd34..0000000 --- a/tests/IntegrationTest.php +++ /dev/null @@ -1,57 +0,0 @@ -autoDiscoverTypes(__DIR__ . '/FakeClasses/Integration') - ->defaultTypeReplacements([ - DateTime::class => 'string', - ]) - ->transformers([ - MyclabsEnumTransformer::class, - DtoTransformer::class, - ]) - ->collectors([ - DefaultCollector::class, - ]); -} - -it('works', function () { - $temporaryDirectory = (new TemporaryDirectory())->create(); - - $transformer = new TypeScriptTransformer( - getTransformerConfig() - ->outputFile($temporaryDirectory->path('types.d.ts')) - ); - - $transformer->transform(); - - $transformed = file_get_contents($temporaryDirectory->path('types.d.ts')); - - assertMatchesSnapshot($transformed); -}); - -it('can transform to es modules', function () { - $temporaryDirectory = (new TemporaryDirectory())->create(); - - $transformer = new TypeScriptTransformer( - getTransformerConfig() - ->writer(ModuleWriter::class) - ->outputFile($temporaryDirectory->path('types.ts')) - ); - - $transformer->transform(); - - $transformed = file_get_contents($temporaryDirectory->path('types.ts')); - - assertMatchesSnapshot($transformed); -}); diff --git a/tests/Laravel/Actions/ResolveLaravelRoutControllerCollectionsActionTest.php b/tests/Laravel/Actions/ResolveLaravelRoutControllerCollectionsActionTest.php new file mode 100644 index 0000000..9ab981a --- /dev/null +++ b/tests/Laravel/Actions/ResolveLaravelRoutControllerCollectionsActionTest.php @@ -0,0 +1,300 @@ +in(__DIR__.'/../'); + +it('can resolve all possible routes', function (Closure $route, Closure $expectations) { + $route(app(Router::class)); + + $routes = app(ResolveLaravelRoutControllerCollectionsAction::class)->execute(null, true); + + $expectations($routes); +})->with(function () { + yield 'simple closure' => [ + fn (Router $router) => $router->get('simple', fn () => 'simple'), + function (RouteCollection $routes) { + expect($routes->controllers)->toBeEmpty(); + expect($routes->closures)->toHaveCount(1); + + expect($routes->closures['Closure(simple)']->url)->toBe('simple'); + expect($routes->closures['Closure(simple)']->methods)->toBe(['GET', 'HEAD']); + }, + ]; + yield 'controller action' => [ + fn (Router $router) => $router->get('action', [ResourceController::class, 'update']), + function (RouteCollection $routes) { + expect($routes->controllers)->toHaveCount(1); + expect($routes->closures)->toBeEmpty(); + + $actions = $routes->controllers['.Spatie.TypeScriptTransformer.Tests.Laravel.FakeClasses.ResourceController']->actions; + + expect($actions)->toHaveCount(1); + expect($actions['update'])->toBeInstanceOf(RouteControllerAction::class); + expect($actions['update']->url)->toBe('action'); + expect($actions['update']->methods)->toBe(['GET', 'HEAD']); + + expect($actions['update']->parameters)->toBeInstanceOf(RouteParameterCollection::class); + expect($actions['update']->parameters->parameters)->toBeEmpty(); + }, + ]; + yield 'invokable controller' => [ + fn (Router $router) => $router->get('invokable', InvokableController::class), + function (RouteCollection $routes) { + expect($routes->controllers)->toHaveCount(1); + expect($routes->closures)->toBeEmpty(); + + $controller = $routes->controllers['.Spatie.TypeScriptTransformer.Tests.Laravel.FakeClasses.InvokableController']; + + expect($controller)->toBeInstanceOf(RouteInvokableController::class); + expect($controller->url)->toBe('invokable'); + expect($controller->methods)->toBe(['GET', 'HEAD']); + + expect($controller->parameters)->toBeInstanceOf(RouteParameterCollection::class); + expect($controller->parameters->parameters)->toBeEmpty(); + }, + ]; + yield 'resource controller' => [ + fn (Router $router) => $router->resource('resource', ResourceController::class), + function (RouteCollection $routes) { + expect($routes->controllers)->toHaveCount(1); + expect($routes->closures)->toBeEmpty(); + + $controller = $routes->controllers['.Spatie.TypeScriptTransformer.Tests.Laravel.FakeClasses.ResourceController']; + + expect($controller)->toBeInstanceOf(RouteController::class); + expect($controller->actions)->toHaveCount(7); + + expect($controller->actions['index'])->toBeInstanceOf(RouteControllerAction::class); + expect($controller->actions['index']->url)->toBe('resource'); + expect($controller->actions['index']->methods)->toBe(['GET', 'HEAD']); + expect($controller->actions['index']->parameters->parameters)->toBeEmpty(); + + expect($controller->actions['create'])->toBeInstanceOf(RouteControllerAction::class); + expect($controller->actions['create']->url)->toBe('resource/create'); + expect($controller->actions['create']->methods)->toBe(['GET', 'HEAD']); + expect($controller->actions['create']->parameters->parameters)->toBeEmpty(); + + expect($controller->actions['store'])->toBeInstanceOf(RouteControllerAction::class); + expect($controller->actions['store']->url)->toBe('resource'); + expect($controller->actions['store']->methods)->toBe(['POST']); + expect($controller->actions['store']->parameters->parameters)->toBeEmpty(); + + expect($controller->actions['show'])->toBeInstanceOf(RouteControllerAction::class); + expect($controller->actions['show']->url)->toBe('resource/{resource}'); + expect($controller->actions['show']->methods)->toBe(['GET', 'HEAD']); + expect($controller->actions['show']->parameters->parameters)->toHaveCount(1); + + expect($controller->actions['edit'])->toBeInstanceOf(RouteControllerAction::class); + expect($controller->actions['edit']->url)->toBe('resource/{resource}/edit'); + expect($controller->actions['edit']->methods)->toBe(['GET', 'HEAD']); + expect($controller->actions['edit']->parameters->parameters)->toHaveCount(1); + + expect($controller->actions['update'])->toBeInstanceOf(RouteControllerAction::class); + expect($controller->actions['update']->url)->toBe('resource/{resource}'); + expect($controller->actions['update']->methods)->toBe(['PUT', 'PATCH']); + expect($controller->actions['update']->parameters->parameters)->toHaveCount(1); + + expect($controller->actions['destroy'])->toBeInstanceOf(RouteControllerAction::class); + expect($controller->actions['destroy']->url)->toBe('resource/{resource}'); + expect($controller->actions['destroy']->methods)->toBe(['DELETE']); + expect($controller->actions['destroy']->parameters->parameters)->toHaveCount(1); + }, + ]; + yield 'nested' => [ + fn (Router $router) => $router->group(['prefix' => 'nested'], fn (Router $router) => $router->get('simple', fn () => 'simple')), + function (RouteCollection $routes) { + expect($routes->controllers)->toBeEmpty(); + expect($routes->closures)->toHaveCount(1); + + expect($routes->closures['Closure(nested/simple)']->url)->toBe('nested/simple'); + expect($routes->closures['Closure(nested/simple)']->methods)->toBe(['GET', 'HEAD']); + }, + ]; + yield 'methods' => [ + function (Router $router) { + $router->get('get', fn () => 'get'); + $router->post('post', fn () => 'post'); + $router->put('put', fn () => 'put'); + $router->patch('patch', fn () => 'patch'); + $router->delete('delete', fn () => 'delete'); + $router->options('options', fn () => 'options'); + }, + function (RouteCollection $routes) { + expect($routes->controllers)->toBeEmpty(); + expect($routes->closures)->toHaveCount(6); + + expect($routes->closures['Closure(get)']->methods)->toBe(['GET', 'HEAD']); + expect($routes->closures['Closure(post)']->methods)->toBe(['POST']); + expect($routes->closures['Closure(put)']->methods)->toBe(['PUT']); + expect($routes->closures['Closure(patch)']->methods)->toBe(['PATCH']); + expect($routes->closures['Closure(delete)']->methods)->toBe(['DELETE']); + expect($routes->closures['Closure(options)']->methods)->toBe(['OPTIONS']); + }, + ]; + yield 'parameter' => [ + fn (Router $router) => $router->get('simple/{id}', fn () => 'simple'), + function (RouteCollection $routes) { + expect($routes->controllers)->toBeEmpty(); + expect($routes->closures)->toHaveCount(1); + + expect($routes->closures['Closure(simple/{id})']->url)->toBe('simple/{id}'); + expect($routes->closures['Closure(simple/{id})']->methods)->toBe(['GET', 'HEAD']); + expect($routes->closures['Closure(simple/{id})']->parameters->parameters)->toHaveCount(1); + expect($routes->closures['Closure(simple/{id})']->parameters->parameters[0]->name)->toBe('id'); + expect($routes->closures['Closure(simple/{id})']->parameters->parameters[0]->optional)->toBeFalse(); + }, + ]; + yield 'nullable parameter' => [ + fn (Router $router) => $router->get('simple/{id?}', fn () => 'simple'), + function (RouteCollection $routes) { + expect($routes->controllers)->toBeEmpty(); + expect($routes->closures)->toHaveCount(1); + + expect($routes->closures['Closure(simple/{id?})']->url)->toBe('simple/{id}'); + expect($routes->closures['Closure(simple/{id?})']->methods)->toBe(['GET', 'HEAD']); + expect($routes->closures['Closure(simple/{id?})']->parameters->parameters)->toHaveCount(1); + expect($routes->closures['Closure(simple/{id?})']->parameters->parameters[0]->name)->toBe('id'); + expect($routes->closures['Closure(simple/{id?})']->parameters->parameters[0]->optional)->toBeTrue(); + }, + ]; + yield 'named routes' => [ + function (Router $router) { + $router->get('simple', fn () => 'simple')->name('simple'); + $router->get('invokable', InvokableController::class)->name('invokable'); + $router->resource('resource', ResourceController::class); + }, + function (RouteCollection $routes) { + expect($routes->controllers)->toHaveCount(2); + expect($routes->closures)->toHaveCount(1); + + expect($routes->closures['Closure(simple)']->name)->toBe('simple'); + + expect($routes->controllers['.Spatie.TypeScriptTransformer.Tests.Laravel.FakeClasses.InvokableController']->name)->toBe('invokable'); + + $resourceController = $routes->controllers['.Spatie.TypeScriptTransformer.Tests.Laravel.FakeClasses.ResourceController']; + + expect($resourceController->actions['index']->name)->toBe('resource.index'); + expect($resourceController->actions['show']->name)->toBe('resource.show'); + expect($resourceController->actions['create']->name)->toBe('resource.create'); + expect($resourceController->actions['update']->name)->toBe('resource.update'); + expect($resourceController->actions['store']->name)->toBe('resource.store'); + expect($resourceController->actions['edit']->name)->toBe('resource.edit'); + expect($resourceController->actions['destroy']->name)->toBe('resource.destroy'); + }, + ]; +}); + +it('can omit certain parts of a specified namespace', function () { + app(Router::class)->get('error', ErrorController::class); + app(Router::class)->get('invokable', InvokableController::class); + + $routes = app(ResolveLaravelRoutControllerCollectionsAction::class)->execute('Spatie\TypeScriptTransformer\Tests\Laravel\FakeClasses', true); + + expect($routes->controllers)->toHaveCount(2)->toHaveKeys([ + '.Symfony.Component.HttpKernel.Controller.ErrorController', + 'InvokableController', + ]); +}); + +it('can filter out certain routes', function ( + WithoutRoutes $withoutRoutes, + Closure $expectations +) { + app(Router::class)->get('simple', fn () => 'simple')->name('simple'); + app(Router::class)->get('invokable', InvokableController::class)->name('invokable'); + app(Router::class)->resource('resource', ResourceController::class); + + $routes = app(ResolveLaravelRoutControllerCollectionsAction::class)->execute(null, true, [$withoutRoutes]); + + $expectations($routes); +})->with(function () { + yield 'named' => [ + WithoutRoutes::named('simple'), + function (RouteCollection $routes) { + expect($routes->closures)->toBeEmpty(); + expect($routes->controllers)->toHaveCount(2); + }, + ]; + yield 'multiple named' => [ + WithoutRoutes::named('simple', 'resource.index', 'resource.edit'), + function (RouteCollection $routes) { + expect($routes->closures)->toBeEmpty(); + expect($routes->controllers) + ->toHaveCount(2) + ->toHaveKeys([ + '.Spatie.TypeScriptTransformer.Tests.Laravel.FakeClasses.ResourceController', + '.Spatie.TypeScriptTransformer.Tests.Laravel.FakeClasses.InvokableController', + ]); + expect($routes->controllers['.Spatie.TypeScriptTransformer.Tests.Laravel.FakeClasses.ResourceController']->actions) + ->toHaveCount(5) + ->toHaveKeys([ + 'show', + 'create', + 'update', + 'store', + 'destroy', + ]); + }, + ]; + yield 'wildcard name' => [ + WithoutRoutes::named('invokable', 'resource.*'), + function (RouteCollection $routes) { + expect($routes->closures)->toHaveCount(1); + expect($routes->controllers)->toHaveCount(0); + }, + ]; + yield 'controller' => [ + WithoutRoutes::controller(ResourceController::class), + function (RouteCollection $routes) { + expect($routes->closures)->toHaveCount(1); + expect($routes->controllers)->toHaveCount(1)->toHaveKey('.Spatie.TypeScriptTransformer.Tests.Laravel.FakeClasses.InvokableController'); + }, + ]; + yield 'multiple controllers' => [ + WithoutRoutes::controller(ResourceController::class), + function (RouteCollection $routes) { + expect($routes->closures)->toHaveCount(1); + expect($routes->controllers)->toHaveCount(1)->toHaveKey('.Spatie.TypeScriptTransformer.Tests.Laravel.FakeClasses.InvokableController'); + }, + ]; + yield 'controller wildcard' => [ + WithoutRoutes::controller('Spatie\TypeScriptTransformer\Tests\Laravel\FakeClasses\*'), + function (RouteCollection $routes) { + expect($routes->closures)->toHaveCount(1); + expect($routes->controllers)->toHaveCount(0); + }, + ]; + yield 'controller action' => [ + WithoutRoutes::controller([ResourceController::class, 'index'], [ResourceController::class, 'edit']), + function (RouteCollection $routes) { + expect($routes->closures)->toHaveCount(1); + expect($routes->controllers) + ->toHaveCount(2) + ->toHaveKeys([ + '.Spatie.TypeScriptTransformer.Tests.Laravel.FakeClasses.ResourceController', + '.Spatie.TypeScriptTransformer.Tests.Laravel.FakeClasses.InvokableController', + ]); + expect($routes->controllers['.Spatie.TypeScriptTransformer.Tests.Laravel.FakeClasses.ResourceController']->actions) + ->toHaveCount(5) + ->toHaveKeys([ + 'show', + 'create', + 'update', + 'store', + 'destroy', + ]); + }, + ]; +}); diff --git a/tests/Laravel/FakeClasses/InvokableController.php b/tests/Laravel/FakeClasses/InvokableController.php new file mode 100644 index 0000000..bf62c29 --- /dev/null +++ b/tests/Laravel/FakeClasses/InvokableController.php @@ -0,0 +1,11 @@ +getActionTypes($route); + + expect($actionTypes)->toBe([ + 'controller' => 'TestController', + 'method' => 'test', + ]); +}); diff --git a/tests/Laravel/LaravelTestCase.php b/tests/Laravel/LaravelTestCase.php new file mode 100644 index 0000000..33479d4 --- /dev/null +++ b/tests/Laravel/LaravelTestCase.php @@ -0,0 +1,16 @@ +add(transformSingle($class, $transformer)); + } + + $referenceMap = (new ConnectReferencesAction(TypeScriptTransformerLog::createNullLog()))->execute($collection); + + $writer = new MemoryWriter(); + + ($writer)->output($collection, $referenceMap); + + return $writer->getOutput(); +} + +function transformSingle( + string|object $class, + ?Transformer $transformer = null +): Transformed|Untransformable { + $transformer ??= new AllClassTransformer(); + + $transformTypesAction = new TransformTypesAction(); + + [$transformed] = $transformTypesAction->execute( + [$transformer], + [PhpClassNode::fromClassString(is_string($class) ? $class : $class::class)], + ); + + return $transformed ?? Untransformable::create(); +} diff --git a/tests/Structures/TypesCollectionTest.php b/tests/Structures/TypesCollectionTest.php deleted file mode 100644 index dc28587..0000000 --- a/tests/Structures/TypesCollectionTest.php +++ /dev/null @@ -1,111 +0,0 @@ -withoutNamespace(); - - assertCount(1, $structure); - assertEquals([ - 'Enum' => $fake, - ], iterator_to_array($structure)); -}); - -it('can add types in a multi layered namespaces', function () { - $structure = TypesCollection::create(); - - $structure[] = $fakeC = FakeTransformedType::fake('Enum')->withNamespace('a\b\c'); - $structure[] = $fakeB = FakeTransformedType::fake('Enum')->withNamespace('a\b'); - $structure[] = $fakeA = FakeTransformedType::fake('Enum')->withNamespace('a'); - $structure[] = $fake = FakeTransformedType::fake('Enum')->withoutNamespace(); - - assertCount(4, $structure); - assertEquals([ - 'Enum' => $fake, - 'a\Enum' => $fakeA, - 'a\b\Enum' => $fakeB, - 'a\b\c\Enum' => $fakeC, - ], iterator_to_array($structure)); -}); - -it('can add multiple types to one namespace', function () { - $structure = TypesCollection::create(); - - $structure[] = $fakeA = FakeTransformedType::fake('EnumA')->withNamespace('test'); - $structure[] = $fakeB = FakeTransformedType::fake('EnumB')->withNamespace('test'); - - assertCount(2, $structure); - assertEquals([ - 'test\EnumA' => $fakeA, - 'test\EnumB' => $fakeB, - ], iterator_to_array($structure)); -}); - -it('can add a real type', function () { - $reflection = new ReflectionClass(TypeScriptEnum::class); - - $structure = TypesCollection::create(); - - $structure[] = $fake = FakeTransformedType::fake('TypeScriptEnum')->withReflection($reflection); - - assertCount(1, $structure); - assertEquals([ - TypeScriptEnum::class => $fake, - ], iterator_to_array($structure)); -}); - -it('cannot have a namespace and type with the same name', function () { - $collection = TypesCollection::create(); - - $collection[] = $fakeA = FakeTransformedType::fake('Enum')->withNamespace('Enum'); - $collection[] = $fakeB = FakeTransformedType::fake('Enum')->withoutNamespace(); -})->throws(SymbolAlreadyExists::class); - -it('cannot have a namespace and type with the same name reversed', function () { - $collection = TypesCollection::create(); - - $collection[] = $fakeB = FakeTransformedType::fake('Enum')->withoutNamespace(); - $collection[] = $fakeA = FakeTransformedType::fake('Enum')->withNamespace('Enum'); -})->throws(SymbolAlreadyExists::class); - -it('can get a type', function () { - $collection = TypesCollection::create(); - - $collection[] = $fake = FakeTransformedType::fake('Enum')->withNamespace('a\b\c'); - - assertEquals($fake, $collection['a\b\c\Enum']); -}); - -it('can get a type in the root namespace', function () { - $collection = TypesCollection::create(); - - $collection[] = $fake = FakeTransformedType::fake('Enum')->withoutNamespace(); - - assertEquals($fake, $collection['Enum']); -}); - -it('when searching a non existing type null is returned', function () { - $collection = TypesCollection::create(); - - assertNull($collection['Enum']); - assertNull($collection['a\b\Enum']); - assertNull($collection['a\b\Enum']); -}); - -it('can add inline types without structure checking', function () { - $collection = TypesCollection::create(); - - $collection[] = $fakeA = FakeTransformedType::fake('Enum')->withoutNamespace()->isInline(); - $collection[] = $fakeB = FakeTransformedType::fake('Enum')->withNamespace('Enum'); - - assertEquals($fakeA, $collection['Enum']); - assertEquals($fakeB, $collection['Enum\Enum']); -}); diff --git a/tests/Support/AllClassTransformer.php b/tests/Support/AllClassTransformer.php new file mode 100644 index 0000000..3bbb1e8 --- /dev/null +++ b/tests/Support/AllClassTransformer.php @@ -0,0 +1,14 @@ +transformed = is_array($transformed) ? $transformed : [$transformed]; + + foreach ($this->transformed as $key => $transformed) { + if ($transformed instanceof TransformedFactory) { + $this->transformed[$key] = $transformed->build(); + } + } + } + + public function provide(TypeScriptTransformerConfig $config, TransformedCollection $types): void + { + foreach ($this->transformed as $transformed) { + $types->add($transformed); + } + } +} diff --git a/tests/Support/MemoryWriter.php b/tests/Support/MemoryWriter.php new file mode 100644 index 0000000..dd3471a --- /dev/null +++ b/tests/Support/MemoryWriter.php @@ -0,0 +1,38 @@ +getName() === $name) { + return $transformed->typeScriptNode; + } + } + } + + public function getOutput(): string + { + $writer = new FlatWriter('test.ts'); + + [$writeableFile] = $writer->output(static::$collection); + + return $writeableFile->contents; + } +} diff --git a/tests/Support/TransformationContextTest.php b/tests/Support/TransformationContextTest.php new file mode 100644 index 0000000..2169746 --- /dev/null +++ b/tests/Support/TransformationContextTest.php @@ -0,0 +1,42 @@ +name)->toBe('SimpleClass'); + expect($context->nameSpaceSegments)->toBe(['Spatie', 'TypeScriptTransformer', 'Tests', 'Fakes', 'TypesToProvide']); + expect($context->optional)->toBeFalse(); +}); + +it('can make a class optional by attribute in its context', function () { + $reflection = PhpClassNode::fromClassString(OptionalAttributedClass::class); + + $context = TransformationContext::createFromPhpClass($reflection); + + expect($context->optional)->toBeTrue(); +}); + +it('can set the name by attribute', function () { + $reflection = PhpClassNode::fromClassString(TypeScriptAttributedClass::class); + + $context = TransformationContext::createFromPhpClass($reflection); + + expect($context->name)->toBe('JustAnotherName'); +}); + +it('can set the location by attribute', function () { + $reflection = PhpClassNode::fromClassString(TypeScriptLocationAttributedClass::class); + + $context = TransformationContext::createFromPhpClass($reflection); + + expect($context->nameSpaceSegments)->toBe(['App', 'Here']); +}); diff --git a/tests/Transformed/TransformedTest.php b/tests/Transformed/TransformedTest.php new file mode 100644 index 0000000..3e054e3 --- /dev/null +++ b/tests/Transformed/TransformedTest.php @@ -0,0 +1,155 @@ +typeScriptNode)->toBeInstanceOf(TypeScriptNamedNode::class); + expect($transformed->getName())->toBe('StringBackedEnum'); +}); + +it('can get the name of a transformed when having a forwarding named node', function () { + $transformed = transformSingle( + \Spatie\TypeScriptTransformer\Tests\Fakes\TypesToProvide\StringBackedEnum::class, + new EnumTransformer(useUnionEnums: true) + ); + + expect($transformed->typeScriptNode)->toBeInstanceOf(TypeScriptForwardingNamedNode::class); + expect($transformed->getName())->toBe('StringBackedEnum'); +}); + +it('can manually set the name of a transformed', function () { + $transformed = transformSingle( + \Spatie\TypeScriptTransformer\Tests\Fakes\TypesToProvide\StringBackedEnum::class, + new EnumTransformer(useUnionEnums: false) + ); + + $transformed->nameAs('MyEnum'); + + expect($transformed->getName())->toBe('MyEnum'); +}); + +it('can add a missing reference', function () { + $missing = transformSingle(SimpleClass::class); + + $transformed = new Transformed( + new TypeScriptObject([ + new TypeScriptProperty('first_name', $typeReferenceA = new TypeReference($missing->reference)), + new TypeScriptProperty('last_name', $typeReferenceB = new TypeReference($missing->reference)), + ]), + new CustomReference('vendor', 'package'), + [], + ); + + $transformed->addMissingReference( + $missing->reference, + $typeReferenceA + ); + + expect($transformed->missingReferences)->toBe([ + $missing->reference->getKey() => [$typeReferenceA], + ]); + + $transformed->addMissingReference( + $missing->reference, + $typeReferenceB + ); + + expect($transformed->missingReferences)->toBe([ + $missing->reference->getKey() => [$typeReferenceA, $typeReferenceB], + ]); +}); + +it('can mark a missing reference as found', function () { + $missing = transformSingle(SimpleClass::class); + + $transformed = new Transformed( + new TypeScriptObject([ + new TypeScriptProperty('first_name', $typeReferenceA = new TypeReference($missing->reference)), + new TypeScriptProperty('last_name', $typeReferenceB = new TypeReference($missing->reference)), + ]), + new CustomReference('vendor', 'package'), + [], + ); + + $transformed->addMissingReference( + $missing->reference, + $typeReferenceA + ); + + $transformed->addMissingReference( + $missing->reference, + $typeReferenceB + ); + + $missing->changed = false; + $transformed->changed = false; + + $transformed->markMissingReferenceFound($missing); + + expect($missing->changed)->toBeFalse(); + expect($transformed->changed)->toBeTrue(); + + expect($transformed->missingReferences)->toBeEmpty(); + expect($transformed->references[$missing->reference->getKey()])->toBe([ + $typeReferenceA, + $typeReferenceB, + ]); + + expect($typeReferenceA->referenced)->toBe($missing); + expect($typeReferenceB->referenced)->toBe($missing); + + expect($missing->referencedBy)->toBe([$transformed->reference->getKey()]); +}); + +it('can mark a reference as missing', function () { + $found = transformSingle(SimpleClass::class); + + $transformed = new Transformed( + new TypeScriptObject([ + new TypeScriptProperty('first_name', $typeReferenceA = new TypeReference($found->reference)), + new TypeScriptProperty('last_name', $typeReferenceB = new TypeReference($found->reference)), + ]), + new CustomReference('vendor', 'package'), + [], + ); + + $connector = new ConnectReferencesAction( + new TypeScriptTransformerLog(new WrappedNullConsole()) + ); + + $connector->execute(new TransformedCollection([$found, $transformed])); + + $transformed->changed = false; + $found->changed = false; + + expect($transformed->missingReferences)->toBeEmpty(); + + $transformed->markReferenceMissing($found); + + expect($transformed->changed)->toBeTrue(); + + expect($transformed->references)->toBeEmpty(); + expect($transformed->missingReferences)->toBe([ + $found->reference->getKey() => [$typeReferenceA, $typeReferenceB], + ]); + + expect($typeReferenceA->referenced)->toBeNull(); + expect($typeReferenceB->referenced)->toBeNull(); +}); diff --git a/tests/Transformers/ClassTransformerTest.php b/tests/Transformers/ClassTransformerTest.php new file mode 100644 index 0000000..bb52d41 --- /dev/null +++ b/tests/Transformers/ClassTransformerTest.php @@ -0,0 +1,342 @@ +getName())->toBe('SimpleClass'); + expect($transformed->typeScriptNode)->toEqual( + new TypeScriptAlias( + new TypeScriptIdentifier('SimpleClass'), + new TypeScriptObject([ + new TypeScriptProperty( + new TypeScriptIdentifier('stringProperty'), + new TypeScriptString() + ), + new TypeScriptProperty( + new TypeScriptIdentifier('constructorPromotedStringProperty'), + new TypeScriptString() + ), + ]) + ) + ); + expect($transformed->reference)->toEqual( + new PhpClassReference(PhpClassNode::fromClassString(SimpleClass::class)) + ); + expect($transformed->location)->toEqual(['Spatie', 'TypeScriptTransformer', 'Tests', 'Fakes', 'TypesToProvide']); + expect($transformed->export)->toBeTrue(); + expect($transformed->references)->toHaveCount(0); +}); + +it('can transform a class by depending on a TypeScriptTypeAttributeContract attribute type', function () { + #[LiteralTypeScriptType('string')] + class TestTypeScriptTypeAttributeContractForClass + { + } + + $transformed = transformSingle(TestTypeScriptTypeAttributeContractForClass::class); + + expect($transformed->typeScriptNode)->toEqual( + new TypeScriptAlias( + new TypeScriptIdentifier('TestTypeScriptTypeAttributeContractForClass'), + new TypeScriptRaw('string'), + ) + ); +}); + +it('transforms only public non static properties by default', function () { + $class = new class () { + public string $public; + + protected string $protected; + + private string $private; + + public static string $publicStatic; + + protected static string $protectedStatic; + + private static string $privateStatic; + }; + + expect(transformSingle($class)->typeScriptNode->type)->toEqual( + new TypeScriptObject([ + new TypeScriptProperty( + new TypeScriptIdentifier('public'), + new TypeScriptString() + ), + ]) + ); +}); + + +it('can type a property using php reflection types', function () { + $class = new class () { + public string $name; + }; + + expect(transformSingle($class)->typeScriptNode->type)->toEqual( + new TypeScriptObject([ + new TypeScriptProperty( + new TypeScriptIdentifier('name'), + new TypeScriptString() + ), + ]) + ); +}); + +it('can type a property using a var annotation', function () { + $class = new class () { + /** @var string */ + public $name; + }; + + expect(transformSingle($class)->typeScriptNode->type)->toEqual( + new TypeScriptObject([ + new TypeScriptProperty( + new TypeScriptIdentifier('name'), + new TypeScriptString() + ), + ]) + ); +}); + + +it('can type a property using a constructor annotation', function () { + $class = new class ('') { + /** + * @param string $name + */ + public function __construct( + public $name, + ) { + } + }; + + expect(transformSingle($class)->typeScriptNode->type)->toEqual( + new TypeScriptObject([ + new TypeScriptProperty( + new TypeScriptIdentifier('name'), + new TypeScriptString() + ), + ]) + ); +}); + +it('can type a property using a class property annotation', function () { + /** + * @property string $name + */ + class TestClassPropertyAnnotation + { + public $name; + } + + expect(transformSingle(TestClassPropertyAnnotation::class)->typeScriptNode->type)->toEqual( + new TypeScriptObject([ + new TypeScriptProperty( + new TypeScriptIdentifier('name'), + new TypeScriptString() + ), + ]) + ); +}); + +it('can type a property using a TypeScriptTypeAttributeContract attribute type', function () { + $class = new class () { + #[TypeScriptType('string')] + public $name; + }; + + expect(transformSingle($class)->typeScriptNode->type)->toEqual( + new TypeScriptObject([ + new TypeScriptProperty( + new TypeScriptIdentifier('name'), + new TypeScriptString() + ), + ]) + ); +}); + +it('can make a typescript property optional by attribute', function () { + $class = new class () { + #[Optional] + public string $name; + }; + + expect(transformSingle($class)->typeScriptNode->type)->toEqual( + new TypeScriptObject([ + new TypeScriptProperty( + new TypeScriptIdentifier('name'), + new TypeScriptString(), + isOptional: true + ), + ]) + ); +}); + +it('can make a complete class optional by attribute', function () { + #[Optional] + class TestAllPropertiesOptionalByClassAttribute + { + public string $name; + public int $age; + } + + expect(transformSingle(TestAllPropertiesOptionalByClassAttribute::class)->typeScriptNode->type)->toEqual( + new TypeScriptObject([ + new TypeScriptProperty( + new TypeScriptIdentifier('name'), + new TypeScriptString(), + isOptional: true + ), + new TypeScriptProperty( + new TypeScriptIdentifier('age'), + new TypeScriptNumber(), + isOptional: true + ), + ]) + ); +}); + +it('will type an untyped property as unknown', function () { + $class = new class () { + public $name; + }; + + expect(transformSingle($class)->typeScriptNode->type)->toEqual( + new TypeScriptObject([ + new TypeScriptProperty( + new TypeScriptIdentifier('name'), + new TypeScriptUnknown() + ), + ]) + ); +}); + +it('can make a TypeScript property readonly by adding the modifier to the property', function () { + $class = new class () { + public readonly string $name; + }; + + expect(transformSingle($class)->typeScriptNode->type)->toEqual( + new TypeScriptObject([ + new TypeScriptProperty( + new TypeScriptIdentifier('name'), + new TypeScriptString(), + isReadonly: true + ), + ]) + ); +}); + +it('can make a TypeScript property readonly by adding the modifier to the class', function () { + expect(transformSingle(ReadonlyClass::class)->typeScriptNode->type)->toEqual( + new TypeScriptObject([ + new TypeScriptProperty( + new TypeScriptIdentifier('property'), + new TypeScriptString(), + isReadonly: true + ), + ]) + ); +}); + +it('can hide a property by adding a hidden attribute', function () { + $class = new class () { + #[Hidden] + public string $property; + }; + + expect(transformSingle($class)->typeScriptNode->type)->toEqual( + new TypeScriptObject([]) + ); +}); + +it('can run a class property processor', function () { + $class = new class () { + public string $name; + }; + + $object = transformSingle($class, transformer: new class () extends AllClassTransformer { + protected function classPropertyProcessors(): array + { + return [ + new class () implements ClassPropertyProcessor { + public function execute(PhpPropertyNode $phpPropertyNode, ?TypeNode $annotation, TypeScriptProperty $property): ?TypeScriptProperty + { + $property->name = new TypeScriptIdentifier('newName'); + $property->type = new TypeScriptNumber(); + $property->isOptional = true; + $property->isReadonly = true; + + return $property; + } + }, + ]; + } + })->typeScriptNode->type; + + expect($object)->toEqual( + new TypeScriptObject([ + new TypeScriptProperty( + new TypeScriptIdentifier('newName'), + new TypeScriptNumber(), + isOptional: true, + isReadonly: true + ), + ]) + ); +}); + +it('can use a class property processor to remove a property', function () { + $class = new class () { + public string $name; + }; + + $object = transformSingle($class, transformer: new class () extends ClassTransformer { + protected function shouldTransform(PhpClassNode $phpClassNode): bool + { + return true; + } + + protected function classPropertyProcessors(): array + { + return [ + new class () implements ClassPropertyProcessor { + public function execute(PhpPropertyNode $phpPropertyNode, ?TypeNode $annotation, TypeScriptProperty $property): ?TypeScriptProperty + { + return null; + } + }, + ]; + } + })->typeScriptNode->type; + + expect($object)->toEqual( + new TypeScriptObject([]) + ); +}); diff --git a/tests/Transformers/DtoTransformerTest.php b/tests/Transformers/DtoTransformerTest.php deleted file mode 100644 index cc97add..0000000 --- a/tests/Transformers/DtoTransformerTest.php +++ /dev/null @@ -1,148 +0,0 @@ -defaultTypeReplacements([ - DateTime::class => 'string', - ]); - - $this->transformer = new DtoTransformer($config); -}); - -it('will replace types', function () { - $type = $this->transformer->transform( - new ReflectionClass(Dto::class), - 'Typed' - ); - - assertMatchesTextSnapshot($type->transformed); - assertEquals([ - Enum::class, - RegularEnum::class, - OtherDto::class, - DtoWithChildren::class, - YetAnotherDto::class, - ], $type->missingSymbols->all()); -}); - -it('a type processor can remove properties', function () { - $config = TypeScriptTransformerConfig::create(); - - $transformer = new class($config) extends DtoTransformer { - protected function typeProcessors(): array - { - $onlyStringPropertiesProcessor = new class implements TypeProcessor { - public function process( - Type $type, - ReflectionProperty | ReflectionParameter | ReflectionMethod $reflection, - MissingSymbolsCollection $missingSymbolsCollection - ): ?Type { - return $type instanceof String_ ? $type : null; - } - }; - - return [$onlyStringPropertiesProcessor]; - } - }; - - $type = $transformer->transform( - new ReflectionClass(Dto::class), - 'Typed' - ); - - assertMatchesTextSnapshot($type->transformed); -}); - -it('will take transform as typescript attributes into account', function () { - $class = new class { - #[TypeScriptType('int')] - public $int; - - #[TypeScriptType('int|bool')] - public int $overwritable; - - #[TypeScriptType(['an_int' => 'int', 'a_bool' => 'bool'])] - public $object; - - #[LiteralTypeScriptType('never')] - public $pure_typescript; - - #[LiteralTypeScriptType(['an_any' => 'any', 'a_never' => 'never'])] - public $pure_typescript_object; - - public int $regular_type; - }; - - $type = $this->transformer->transform( - new ReflectionClass($class), - 'Typed' - ); - - assertMatchesSnapshot($type->transformed); -}); - -it('transforms properties to optional ones when using optional attribute', function () { - $class = new class { - #[Optional] - public string $string; - }; - - $type = $this->transformer->transform( - new ReflectionClass($class), - 'Typed' - ); - - assertMatchesSnapshot($type->transformed); -}); - -it('transforms all properties of a class with optional attribute to optional', function () { - #[Optional] - class DummyOptionalDto - { - public string $string; - public int $int; - } - - $type = $this->transformer->transform( - new ReflectionClass(DummyOptionalDto::class), - 'Typed' - ); - - assertMatchesSnapshot($type->transformed); -}); - - -it('transforms properties to hidden ones when using hidden attribute', function () { - $class = new class() { - public string $visible; - #[Hidden] - public string $hidden; - }; - - $type = $this->transformer->transform( - new ReflectionClass($class), - 'Typed' - ); - - assertMatchesSnapshot($type->transformed); -}); diff --git a/tests/Transformers/EnumTransformerTest.php b/tests/Transformers/EnumTransformerTest.php index 6c0c450..6d27088 100644 --- a/tests/Transformers/EnumTransformerTest.php +++ b/tests/Transformers/EnumTransformerTest.php @@ -1,115 +1,82 @@ markTestSkipped('Native enums not supported before PHP 8.1'); - } -}); it('will only convert enums', function () { - $transformer = new EnumTransformer( - TypeScriptTransformerConfig::create()->transformToNativeEnums(false) - ); - - assertNotNull($transformer->transform( - new ReflectionClass(StringBackedEnum::class), - 'Enum', - )); - - assertNull($transformer->transform( - new ReflectionClass(DateTime::class), - 'Enum', - )); + expect(transformSingle(StringBackedEnum::class, new EnumTransformer()))->toBeInstanceOf(Transformed::class); + expect(transformSingle(DateTime::class, new EnumTransformer()))->toBeInstanceOf(Untransformable::class); }); -it('does not transform a unit enum', function () { - $transformer = new EnumTransformer( - TypeScriptTransformerConfig::create()->transformToNativeEnums(false) - ); - - $type = $transformer->transform( - new ReflectionClass(UnitEnum::class), - 'Enum' - ); - - assertNull($type); +it('does not transform a unit enum when using union enums', function () { + expect(transformSingle(UnitEnum::class, new EnumTransformer()))->toBeInstanceOf(Untransformable::class); }); -it('can transform a backed enum into enum', function () { - $transformer = new EnumTransformer( - TypeScriptTransformerConfig::create()->transformToNativeEnums(true) - ); - - $type = $transformer->transform( - new ReflectionClass(StringBackedEnum::class), - 'Enum' - ); - - assertEquals("'JS' = 'js', 'PHP' = 'php'", $type->transformed); - assertTrue($type->missingSymbols->isEmpty()); - assertFalse($type->isInline); - assertEquals('enum', $type->keyword); +it('can transform an unit backed enum into a native enum', function () { + expect(classesToTypeScript([UnitEnum::class], new EnumTransformer(useUnionEnums: false))) + ->toBe(<<transformToNativeEnums(false) - ); - - $type = $transformer->transform( - new ReflectionClass(StringBackedEnum::class), - 'Enum' - ); - - assertEquals("'js' | 'php'", $type->transformed); - assertTrue($type->missingSymbols->isEmpty()); - assertFalse($type->isInline); - assertEquals('type', $type->keyword); +it('can transform an int backed enum into a union enum', function () { + expect(classesToTypeScript([IntBackedEnum::class], new EnumTransformer())) + ->toBe('export type IntBackedEnum = 1 | 2 | 3 | 4;'.PHP_EOL); }); -it('can transform a backed enum with integers into an enm', function () { - $transformer = new EnumTransformer( - TypeScriptTransformerConfig::create()->transformToNativeEnums(true) - ); +it('can transform an int backed enum into a native enum', function () { + expect(classesToTypeScript([IntBackedEnum::class], new EnumTransformer(useUnionEnums: false))) + ->toBe(<<transform( - new ReflectionClass(IntBackedEnum::class), - 'Enum' - ); +it('can transform a string backed enum into a union enum', function () { + expect(classesToTypeScript([StringBackedEnum::class], new EnumTransformer())) + ->toBe('export type StringBackedEnum = "john" | "paul" | "george" | "ringo";' . PHP_EOL); +}); - assertEquals("'JS' = 1, 'PHP' = 2", $type->transformed); - assertTrue($type->missingSymbols->isEmpty()); - assertFalse($type->isInline); - assertEquals('enum', $type->keyword); +it('can transform a string backed enum into a native enum', function () { + expect(classesToTypeScript([StringBackedEnum::class], new EnumTransformer(useUnionEnums: false))) + ->toBe( + <<transformToNativeEnums(false) - ); +it('will not transform empty enums', function () { + $transformer = new EnumTransformer(); - $type = $transformer->transform( - new ReflectionClass(IntBackedEnum::class), - 'Enum' + $transformed = $transformer->transform( + $enum = PhpClassNode::fromClassString(EmptyEnum::class), + TransformationContext::createFromPhpClass($enum), ); - assertEquals("1 | 2", $type->transformed); - assertTrue($type->missingSymbols->isEmpty()); - assertFalse($type->isInline); - assertEquals('type', $type->keyword); + expect($transformed)->toBeInstanceOf(Untransformable::class); }); diff --git a/tests/Transformers/InterfaceTransformerTest.php b/tests/Transformers/InterfaceTransformerTest.php index 784599d..6d31c29 100644 --- a/tests/Transformers/InterfaceTransformerTest.php +++ b/tests/Transformers/InterfaceTransformerTest.php @@ -1,37 +1,12 @@ transform( - new ReflectionClass(DateTimeInterface::class), - 'State', - )); +it('transforms methods in interfaces', function () { + $transformed = classesToTypeScript([SimpleInterface::class], new AllInterfaceTransformer()); - assertNull($transformer->transform( - new ReflectionClass(DateTime::class), - 'State', - )); -}); - -it('will replace methods', function () { - $transformer = new InterfaceTransformer( - TypeScriptTransformerConfig::create() - ); - - $type = $transformer->transform( - new ReflectionClass(FakeInterface::class), - 'State', - ); - - assertMatchesTextSnapshot($type->transformed); + assertMatchesSnapshot($transformed); }); diff --git a/tests/Transformers/MyclabsEnumTransformerTest.php b/tests/Transformers/MyclabsEnumTransformerTest.php deleted file mode 100644 index d4e0500..0000000 --- a/tests/Transformers/MyclabsEnumTransformerTest.php +++ /dev/null @@ -1,60 +0,0 @@ -transformToNativeEnums(false) - ); - - $enum = new class('view') extends Enum { - private const VIEW = 'view'; - private const EDIT = 'edit'; - }; - - $noEnum = new class { - }; - - assertNotNull($transformer->transform(new ReflectionClass($enum), 'Enum')); - assertNull($transformer->transform(new ReflectionClass($noEnum), 'Enum')); -}); - -it('can transform an enum into a type', function () { - $transformer = new MyclabsEnumTransformer( - TypeScriptTransformerConfig::create()->transformToNativeEnums(false) - ); - - $enum = new class('view') extends Enum { - private const VIEW = 'view'; - private const EDIT = 'edit'; - }; - - $type = $transformer->transform(new ReflectionClass($enum), 'Enum'); - - assertEquals("'view' | 'edit'", $type->transformed); - assertTrue($type->missingSymbols->isEmpty()); - assertEquals('type', $type->keyword); -}); - -it('can transform an enum into an enum', function () { - $transformer = new MyclabsEnumTransformer( - TypeScriptTransformerConfig::create()->transformToNativeEnums(true) - ); - - $enum = new class('view') extends Enum { - private const VIEW = 'view'; - private const EDIT = 'edit'; - }; - - $type = $transformer->transform(new ReflectionClass($enum), 'Enum'); - - assertEquals("'VIEW' = 'view', 'EDIT' = 'edit'", $type->transformed); - assertTrue($type->missingSymbols->isEmpty()); - assertEquals('enum', $type->keyword); -}); diff --git a/tests/Transformers/SpatieEnumTransformerTest.php b/tests/Transformers/SpatieEnumTransformerTest.php deleted file mode 100644 index ed8d051..0000000 --- a/tests/Transformers/SpatieEnumTransformerTest.php +++ /dev/null @@ -1,58 +0,0 @@ -transformToNativeEnums(false) - ); - - assertNotNull($transformer->transform( - new ReflectionClass(SpatieEnum::class), - 'State', - )); - - assertNull($transformer->transform( - new ReflectionClass(DateTime::class), - 'State', - )); -}); - -it('can transform an enum into a type', function () { - $transformer = new SpatieEnumTransformer( - TypeScriptTransformerConfig::create()->transformToNativeEnums(false) - ); - - $type = $transformer->transform( - new ReflectionClass(SpatieEnum::class), - 'FakeEnum' - ); - - assertEquals("'draft' | 'published' | 'archived'", $type->transformed); - assertTrue($type->missingSymbols->isEmpty()); - assertFalse($type->isInline); - assertEquals('type', $type->keyword); -}); - -it('can transform an enum into an enum', function () { - $transformer = new SpatieEnumTransformer( - TypeScriptTransformerConfig::create()->transformToNativeEnums(true) - ); - - $type = $transformer->transform( - new ReflectionClass(SpatieEnum::class), - 'FakeEnum' - ); - - assertEquals("'draft' = 'Draft', 'published' = 'Published', 'archived' = 'Archived'", $type->transformed); - assertTrue($type->missingSymbols->isEmpty()); - assertFalse($type->isInline); - assertEquals('enum', $type->keyword); -}); diff --git a/tests/Transformers/__snapshots__/DtoTransformerTest__a_property_processor_can_remove_properties__1.txt b/tests/Transformers/__snapshots__/DtoTransformerTest__a_property_processor_can_remove_properties__1.txt deleted file mode 100644 index d0fe530..0000000 --- a/tests/Transformers/__snapshots__/DtoTransformerTest__a_property_processor_can_remove_properties__1.txt +++ /dev/null @@ -1 +0,0 @@ -{string: string;default: string;documented_string: string;} diff --git a/tests/Transformers/__snapshots__/DtoTransformerTest__a_type_processor_can_remove_properties__1.txt b/tests/Transformers/__snapshots__/DtoTransformerTest__a_type_processor_can_remove_properties__1.txt deleted file mode 100644 index 1cc389a..0000000 --- a/tests/Transformers/__snapshots__/DtoTransformerTest__a_type_processor_can_remove_properties__1.txt +++ /dev/null @@ -1,5 +0,0 @@ -{ -string: string; -default: string; -documented_string: string; -} \ No newline at end of file diff --git a/tests/Transformers/__snapshots__/DtoTransformerTest__it_transforms_nullable_properties_to_optional_ones_according_to_config__1.txt b/tests/Transformers/__snapshots__/DtoTransformerTest__it_transforms_nullable_properties_to_optional_ones_according_to_config__1.txt deleted file mode 100644 index dbf55ed..0000000 --- a/tests/Transformers/__snapshots__/DtoTransformerTest__it_transforms_nullable_properties_to_optional_ones_according_to_config__1.txt +++ /dev/null @@ -1,3 +0,0 @@ -{ -string?: string; -} \ No newline at end of file diff --git a/tests/Transformers/__snapshots__/DtoTransformerTest__it_transforms_nullable_properties_to_optional_ones_when_using_optional_attribute__1.txt b/tests/Transformers/__snapshots__/DtoTransformerTest__it_transforms_nullable_properties_to_optional_ones_when_using_optional_attribute__1.txt deleted file mode 100644 index dbf55ed..0000000 --- a/tests/Transformers/__snapshots__/DtoTransformerTest__it_transforms_nullable_properties_to_optional_ones_when_using_optional_attribute__1.txt +++ /dev/null @@ -1,3 +0,0 @@ -{ -string?: string; -} \ No newline at end of file diff --git a/tests/Transformers/__snapshots__/DtoTransformerTest__it_will_replace_types__1.txt b/tests/Transformers/__snapshots__/DtoTransformerTest__it_will_replace_types__1.txt deleted file mode 100644 index 2c58855..0000000 --- a/tests/Transformers/__snapshots__/DtoTransformerTest__it_will_replace_types__1.txt +++ /dev/null @@ -1,28 +0,0 @@ -{ -string: string; -nullbable: string | null; -default: string; -int: number; -boolean: boolean; -float: number; -object: object; -array: Array; -none: any; -documented_string: string; -mixed: number | string; -documented_array: Array; -mixed_with_array: number | string | Array; -array_with_null: Array; -enum: {%Spatie\TypeScriptTransformer\Tests\FakeClasses\Integration\Enum%}; -non_typescripted_type: {%Spatie\TypeScriptTransformer\Tests\FakeClasses\Enum\RegularEnum%}; -other_dto: {%Spatie\TypeScriptTransformer\Tests\FakeClasses\Integration\OtherDto%}; -other_dto_array: Array<{%Spatie\TypeScriptTransformer\Tests\FakeClasses\Integration\OtherDto%}>; -other_dto_collection: Array<{%Spatie\TypeScriptTransformer\Tests\FakeClasses\Integration\OtherDto%}>; -dto_with_children: {%Spatie\TypeScriptTransformer\Tests\FakeClasses\Integration\DtoWithChildren%}; -another_namespace_dto: {%Spatie\TypeScriptTransformer\Tests\FakeClasses\Integration\LevelUp\YetAnotherDto%}; -nullable_string: string | number | null; -reflection_replaced_default_type: string; -docblock_replaced_default_type: string; -array_replaced_default_type: Array; -array_as_object: { [key: string]: any }; -} \ No newline at end of file diff --git a/tests/Transformers/__snapshots__/DtoTransformerTest__it_will_take_transform_as_typescript_attributes_into_account__1.txt b/tests/Transformers/__snapshots__/DtoTransformerTest__it_will_take_transform_as_typescript_attributes_into_account__1.txt deleted file mode 100644 index a2a0322..0000000 --- a/tests/Transformers/__snapshots__/DtoTransformerTest__it_will_take_transform_as_typescript_attributes_into_account__1.txt +++ /dev/null @@ -1,8 +0,0 @@ -{ -int: number; -overwritable: number | boolean; -object: {an_int:number;a_bool:boolean;}; -pure_typescript: never; -pure_typescript_object: {an_any:any;a_never:never;}; -regular_type: number; -} \ No newline at end of file diff --git a/tests/Transformers/__snapshots__/InterfaceTransformerTest__it_will_replace_methods__1.txt b/tests/Transformers/__snapshots__/InterfaceTransformerTest__it_will_replace_methods__1.txt deleted file mode 100644 index f7072e8..0000000 --- a/tests/Transformers/__snapshots__/InterfaceTransformerTest__it_will_replace_methods__1.txt +++ /dev/null @@ -1,4 +0,0 @@ -{ -testFunction(input: string, output: Array): number; -anotherTestFunction(): boolean; -} \ No newline at end of file diff --git a/tests/TypeProcessors/DtoCollectionTypeProcessorTest.php b/tests/TypeProcessors/DtoCollectionTypeProcessorTest.php deleted file mode 100644 index 3704fb1..0000000 --- a/tests/TypeProcessors/DtoCollectionTypeProcessorTest.php +++ /dev/null @@ -1,75 +0,0 @@ -typeResolver = new TypeResolver(); - - $this->processor = new DtoCollectionTypeProcessor(); -}); - -it('will process a dto collection', function () { - $type = $this->processor->process( - $this->typeResolver->resolve(DtoCollection::class), - FakeReflectionProperty::create(), - new MissingSymbolsCollection() - ); - - assertEquals( - '\Spatie\TypeScriptTransformer\Tests\FakeClasses\Integration\Dto[]', - (string) $type - ); -}); - -it('will process a nullable dto collection', function () { - $type = $this->processor->process( - $this->typeResolver->resolve(NullableDtoCollection::class), - FakeReflectionProperty::create(), - new MissingSymbolsCollection() - ); - - assertEquals( - '?\Spatie\TypeScriptTransformer\Tests\FakeClasses\Integration\Dto[]', - (string) $type - ); -}); - -it('will process a dto collection with built in type', function () { - $type = $this->processor->process( - $this->typeResolver->resolve(StringDtoCollection::class), - FakeReflectionProperty::create(), - new MissingSymbolsCollection() - ); - - assertEquals('string[]', (string) $type); -}); - -it('will process a dto collection without type', function () { - $type = $this->processor->process( - $this->typeResolver->resolve(UntypedDtoCollection::class), - FakeReflectionProperty::create(), - new MissingSymbolsCollection() - ); - - assertEquals(new Array_(new TypeScriptType('any')), $type); -}); - -it('will pass non dto collections', function () { - $type = $this->processor->process( - $this->typeResolver->resolve('string'), - FakeReflectionProperty::create(), - new MissingSymbolsCollection() - ); - - assertEquals('string', (string) $type); -}); diff --git a/tests/TypeProcessors/ProcessesTypesTest.php b/tests/TypeProcessors/ProcessesTypesTest.php deleted file mode 100644 index c0eb569..0000000 --- a/tests/TypeProcessors/ProcessesTypesTest.php +++ /dev/null @@ -1,72 +0,0 @@ -walk($type, $closure); - } - }; - - $initialType = is_string($initialType) - ? (new TypeResolver())->resolve($initialType) - : $initialType; - - $expectedType = is_string($expectedType) - ? (new TypeResolver())->resolve($expectedType) - : $expectedType; - - $found = $processor->run($initialType, $closure); - - assertEquals($expectedType, $found); -} - -it('supports types', function () { - assertProcessed( - 'string', - 'string', - fn (Type $type) => $type, - ); - - assertProcessed( - null, - 'string', - fn (Type $type) => null, - ); - - assertProcessed( - 'Array', - 'Array', - fn (Type $type) => $type, - ); - - assertProcessed( - 'string', - 'string|int', - fn (Type $type) => $type instanceof Integer ? null : $type, - ); - - assertProcessed( - 'int[]', - 'int[]', - fn (Type $type) => $type, - ); - - assertProcessed( - 'Collection', - 'Collection', - fn (Type $type) => $type, - ); -}); diff --git a/tests/TypeProcessors/ReplaceDefaultsTypeProcessorTest.php b/tests/TypeProcessors/ReplaceDefaultsTypeProcessorTest.php deleted file mode 100644 index e21ae2e..0000000 --- a/tests/TypeProcessors/ReplaceDefaultsTypeProcessorTest.php +++ /dev/null @@ -1,51 +0,0 @@ -typeResolver = new TypeResolver(); - - $this->processor = new ReplaceDefaultsTypeProcessor([ - DateTime::class => new String_(), - Dto::class => new TypeScriptType('array'), - ]); -}); - -it('can replace types', function () { - $type = $this->processor->process( - $this->typeResolver->resolve(Dto::class), - FakeReflectionProperty::create(), - new MissingSymbolsCollection() - ); - - assertEquals(new TypeScriptType('array'), $type); -}); - -it('can replace types as nullable', function () { - $type = $this->processor->process( - $this->typeResolver->resolve('?' . DateTime::class), - FakeReflectionProperty::create(), - new MissingSymbolsCollection() - ); - - assertEquals(new Nullable(new String_()), $type); -}); - -it('can replace types in arrays', function () { - $type = $this->processor->process( - $this->typeResolver->resolve(DateTime::class . '[]'), - FakeReflectionProperty::create(), - new MissingSymbolsCollection() - ); - - assertEquals(new Array_(new String_()), $type); -}); diff --git a/tests/TypeProviders/TransformerTypesProviderTest.php b/tests/TypeProviders/TransformerTypesProviderTest.php new file mode 100644 index 0000000..8e7ccc5 --- /dev/null +++ b/tests/TypeProviders/TransformerTypesProviderTest.php @@ -0,0 +1,149 @@ +provide( + TypeScriptTransformerConfigFactory::create()->get(), + $collection = new TransformedCollection() + ); + + return $collection; +} + +it('will find types and takes attributes into account', function () { + $collection = getTestProvidedTypes(); + + expect($collection)->toHaveCount(5); + expect(iterator_to_array($collection))->sequence( + fn (Expectation $transformed) => $transformed + ->toBeInstanceOf(Transformed::class) + ->getName()->toBe('JustAnotherName') + ->typeScriptNode->toEqual(new TypeScriptAlias( + new TypeScriptIdentifier('JustAnotherName'), + new TypeScriptObject([ + new TypeScriptProperty('property', new TypeScriptString()), + ]) + )) + ->reference->toBeInstanceOf(PhpClassReference::class) + ->reference->classString->toBe(TypeScriptAttributedClass::class) + ->location->toBe(['Spatie', 'TypeScriptTransformer', 'Tests', 'Fakes', 'TypesToProvide']), + fn (Expectation $transformed) => $transformed + ->toBeInstanceOf(Transformed::class) + ->getName()->toBe('TypeScriptLocationAttributedClass') + ->typeScriptNode->toEqual(new TypeScriptAlias( + new TypeScriptIdentifier('TypeScriptLocationAttributedClass'), + new TypeScriptObject([ + new TypeScriptProperty('property', new TypeScriptString()), + ]) + )) + ->reference->toBeInstanceOf(PhpClassReference::class) + ->reference->classString->toBe(TypeScriptLocationAttributedClass::class) + ->location->toBe(['App', 'Here']), + fn (Expectation $transformed) => $transformed + ->toBeInstanceOf(Transformed::class) + ->getName()->toBe('OptionalAttributedClass') + ->typeScriptNode->toEqual(new TypeScriptAlias( + new TypeScriptIdentifier('OptionalAttributedClass'), + new TypeScriptObject([ + new TypeScriptProperty('property', new TypeScriptString(), isOptional: true), + ]) + )) + ->reference->toBeInstanceOf(PhpClassReference::class) + ->reference->classString->toBe(OptionalAttributedClass::class) + ->location->toBe(['Spatie', 'TypeScriptTransformer', 'Tests', 'Fakes', 'TypesToProvide']), + fn (Expectation $transformed) => $transformed + ->toBeInstanceOf(Transformed::class) + ->getName()->toBe('ReadonlyClass') + ->typeScriptNode->toEqual(new TypeScriptAlias( + new TypeScriptIdentifier('ReadonlyClass'), + new TypeScriptObject([ + new TypeScriptProperty('property', new TypeScriptString(), isReadonly: true), + ]) + )) + ->reference->toBeInstanceOf(PhpClassReference::class) + ->reference->classString->toBe(ReadonlyClass::class) + ->location->toBe(['Spatie', 'TypeScriptTransformer', 'Tests', 'Fakes', 'TypesToProvide']), + fn (Expectation $transformed) => $transformed + ->toBeInstanceOf(Transformed::class) + ->getName()->toBe('SimpleClass') + ->typeScriptNode->toEqual(new TypeScriptAlias( + new TypeScriptIdentifier('SimpleClass'), + new TypeScriptObject([ + new TypeScriptProperty('stringProperty', new TypeScriptString()), + new TypeScriptProperty('constructorPromotedStringProperty', new TypeScriptString()), + ]) + )) + ->reference->toBeInstanceOf(PhpClassReference::class) + ->reference->classString->toBe(SimpleClass::class) + ->location->toBe(['Spatie', 'TypeScriptTransformer', 'Tests', 'Fakes', 'TypesToProvide']), + ); +}); + +it('will not find hidden classes', function () { + $typeNames = array_map( + fn (Transformed $transformed) => $transformed->reference->classString, + iterator_to_array(getTestProvidedTypes()) + ); + + expect($typeNames) + ->not->toContain(HiddenAttributedClass::class) + ->toContain(SimpleClass::class); +}); + +it('will only transform types it can transform', function () { + $classTypes = array_map( + fn (Transformed $transformed) => $transformed->reference->classString, + iterator_to_array(getTestProvidedTypes([new AllClassTransformer()])) + ); + + expect($classTypes) + ->not->toContain(StringBackedEnum::class) + ->toContain(SimpleClass::class); + + $enumTypes = array_map( + fn (Transformed $transformed) => $transformed->reference->classString, + iterator_to_array(getTestProvidedTypes([new EnumTransformer()])) + ); + + expect($enumTypes) + ->toContain(StringBackedEnum::class) + ->not->toContain(SimpleClass::class); + + $allTypes = array_map( + fn (Transformed $transformed) => $transformed->reference->classString, + iterator_to_array(getTestProvidedTypes([new EnumTransformer(), new AllClassTransformer()])) + ); + + expect($allTypes) + ->toContain(StringBackedEnum::class) + ->toContain(SimpleClass::class); +}); diff --git a/tests/TypeReflectors/ClassTypeReflectorTest.php b/tests/TypeReflectors/ClassTypeReflectorTest.php deleted file mode 100644 index 3780043..0000000 --- a/tests/TypeReflectors/ClassTypeReflectorTest.php +++ /dev/null @@ -1,23 +0,0 @@ -isTransformable()); - assertEquals($inline, $reflected->isInline()); - assertEquals($name, $reflected->getName()); - assertEquals($transformer, $reflected->getTransformerClass()); - assertEquals($type, $reflected->getType()); -})->with('reflection_classes'); diff --git a/tests/TypeReflectors/MethodParameterTypeReflectorTest.php b/tests/TypeReflectors/MethodParameterTypeReflectorTest.php deleted file mode 100644 index 5d8c41a..0000000 --- a/tests/TypeReflectors/MethodParameterTypeReflectorTest.php +++ /dev/null @@ -1,116 +0,0 @@ -getParameters(); - - assertEquals( - 'int', - (string) MethodParameterTypeReflector::create($parameters[0])->reflect() - ); - - assertEquals( - '?int', - (string) MethodParameterTypeReflector::create($parameters[1])->reflect() - ); - - assertEquals( - 'int|float', - (string) MethodParameterTypeReflector::create($parameters[2])->reflect() - ); - - assertEquals( - 'int|float|null', - (string) MethodParameterTypeReflector::create($parameters[3])->reflect() - ); - - assertEquals( - 'any', - (string) MethodParameterTypeReflector::create($parameters[4])->reflect() - ); -}); - -it('can reflect from docblock', function () { - $class = new class { - /** - * @param int $int - * @param ?int $nullable_int - * @param int|float $union - * @param int|float|null $nullable_union - * @param array $array - * @param $without_type - */ - public function method( - $int, - $nullable_int, - $union, - $nullable_union, - $array, - $without_type - ) { - } - }; - - $parameters = (new ReflectionMethod($class, 'method'))->getParameters(); - - assertEquals( - 'int', - (string) MethodParameterTypeReflector::create($parameters[0])->reflect() - ); - - assertEquals( - '?int', - (string) MethodParameterTypeReflector::create($parameters[1])->reflect() - ); - - assertEquals( - 'int|float', - (string) MethodParameterTypeReflector::create($parameters[2])->reflect() - ); - - assertEquals( - 'int|float|null', - (string) MethodParameterTypeReflector::create($parameters[3])->reflect() - ); - - assertEquals( - 'array', - (string) MethodParameterTypeReflector::create($parameters[4])->reflect() - ); - - assertEquals( - 'any', - (string) MethodParameterTypeReflector::create($parameters[5])->reflect() - ); -}); - -it('cannot reflect from attribute', function () { - $class = new class { - #[LiteralTypeScriptType('int')] - public function method( - $int, - ) { - } - }; - - $parameters = (new ReflectionMethod($class, 'method'))->getParameters(); - - assertEquals( - 'any', - (string) MethodParameterTypeReflector::create($parameters[0])->reflect() - ); -}); diff --git a/tests/TypeReflectors/MethodReturnTypeReflectorTest.php b/tests/TypeReflectors/MethodReturnTypeReflectorTest.php deleted file mode 100644 index 532d609..0000000 --- a/tests/TypeReflectors/MethodReturnTypeReflectorTest.php +++ /dev/null @@ -1,143 +0,0 @@ -reflect() - ); - - assertEquals( - '?int', - (string) MethodReturnTypeReflector::create(new ReflectionMethod($class, 'm2'))->reflect() - ); - - assertEquals( - 'int|float', - (string) MethodReturnTypeReflector::create(new ReflectionMethod($class, 'm3'))->reflect() - ); - - assertEquals( - 'int|float|null', - (string) MethodReturnTypeReflector::create(new ReflectionMethod($class, 'm4'))->reflect() - ); - - assertEquals( - 'any', - (string) MethodReturnTypeReflector::create(new ReflectionMethod($class, 'm5'))->reflect() - ); -}); - -it('can reflect from docblock', function () { - $class = new class { - /** @return int */ - public function m1() - { - return 42; - } - - /** @return ?int */ - public function m2() - { - return 42; - } - - /** @return int|float */ - public function m3() - { - return 42; - } - - /** @return int|float|null */ - public function m4() - { - return 42; - } - - public function m5() - { - return 42; - } - - /** @return array */ - public function m6() - { - return []; - } - }; - - assertEquals( - 'int', - (string) MethodReturnTypeReflector::create(new ReflectionMethod($class, 'm1'))->reflect() - ); - - assertEquals( - '?int', - (string) MethodReturnTypeReflector::create(new ReflectionMethod($class, 'm2'))->reflect() - ); - - assertEquals( - 'int|float', - (string) MethodReturnTypeReflector::create(new ReflectionMethod($class, 'm3'))->reflect() - ); - - assertEquals( - 'int|float|null', - (string) MethodReturnTypeReflector::create(new ReflectionMethod($class, 'm4'))->reflect() - ); - - assertEquals( - 'any', - (string) MethodReturnTypeReflector::create(new ReflectionMethod($class, 'm5'))->reflect() - ); - - assertEquals( - 'array', - (string) MethodReturnTypeReflector::create(new ReflectionMethod($class, 'm6'))->reflect() - ); -}); - -it('can reflect from attribute', function () { - $class = new class { - #[LiteralTypeScriptType('Integer')] - public function m1() - { - return 42; - } - }; - - assertEquals( - 'Integer', - (string) MethodReturnTypeReflector::create(new ReflectionMethod($class, 'm1'))->reflect() - ); -}); diff --git a/tests/TypeReflectors/PropertyTypeReflectorTest.php b/tests/TypeReflectors/PropertyTypeReflectorTest.php deleted file mode 100644 index dddaae7..0000000 --- a/tests/TypeReflectors/PropertyTypeReflectorTest.php +++ /dev/null @@ -1,103 +0,0 @@ -reflect() - ); - - assertEquals( - '?int', - (string) PropertyTypeReflector::create(new ReflectionProperty($class, 'p2'))->reflect() - ); - - assertEquals( - 'int|float', - (string) PropertyTypeReflector::create(new ReflectionProperty($class, 'p3'))->reflect() - ); - - assertEquals( - 'int|float|null', - (string) PropertyTypeReflector::create(new ReflectionProperty($class, 'p4'))->reflect() - ); - - assertEquals( - 'any', - (string) PropertyTypeReflector::create(new ReflectionProperty($class, 'p5'))->reflect() - ); -}); - -it('can reflect from docblock', function () { - $class = new class { - /** @var int */ - public $p1; - - /** @var ?int */ - public $p2; - - /** @var int|float */ - public $p3; - - /** @var int|float|null */ - public $p4; - - public $p5; - - /** @var array */ - public $p6; - }; - - assertEquals( - 'int', - (string) PropertyTypeReflector::create(new ReflectionProperty($class, 'p1'))->reflect() - ); - - assertEquals( - '?int', - (string) PropertyTypeReflector::create(new ReflectionProperty($class, 'p2'))->reflect() - ); - - assertEquals( - 'int|float', - (string) PropertyTypeReflector::create(new ReflectionProperty($class, 'p3'))->reflect() - ); - - assertEquals( - 'int|float|null', - (string) PropertyTypeReflector::create(new ReflectionProperty($class, 'p4'))->reflect() - ); - - assertEquals( - 'any', - (string) PropertyTypeReflector::create(new ReflectionProperty($class, 'p5'))->reflect() - ); - - assertEquals( - 'array', - (string) PropertyTypeReflector::create(new ReflectionProperty($class, 'p6'))->reflect() - ); -}); - -it('can reflect from attribute', function () { - $class = new class { - #[LiteralTypeScriptType('Integer')] - public $p1; - }; - - assertEquals( - 'Integer', - (string) PropertyTypeReflector::create(new ReflectionProperty($class, 'p1'))->reflect() - ); -}); diff --git a/tests/TypeReflectors/TypeReflectorTest.php b/tests/TypeReflectors/TypeReflectorTest.php deleted file mode 100644 index 8c65c4b..0000000 --- a/tests/TypeReflectors/TypeReflectorTest.php +++ /dev/null @@ -1,125 +0,0 @@ -withDocComment("@var {$input}") - ); - - assertEquals($outputType, (string) $reflector->reflect()); -})->with('docblock_types'); - -it('will handle no docblock', function () { - $reflector = new PropertyTypeReflector( - FakeReflectionProperty::create() - ); - - assertEquals('any', (string) $reflector->reflect()); -}); - -it('can handle another non var docblock', function () { - $reflector = new PropertyTypeReflector( - FakeReflectionProperty::create()->withDocComment('@method bla') - ); - - assertEquals('any', (string) $reflector->reflect()); -}); - -it('can handle an incorrect docblock', function () { - $reflector = new PropertyTypeReflector( - FakeReflectionProperty::create()->withDocComment('@var int bool') - ); - - assertEquals('int', (string) $reflector->reflect()); -}); - -it('can resolve reflection types', function (string $input, bool $isBuiltIn, string $outputType) { - $reflection = FakeReflectionProperty::create()->withType( - FakeReflectionType::create()->withIsBuiltIn($isBuiltIn)->withType($input) - ); - - $reflector = new PropertyTypeReflector($reflection); - - assertEquals($outputType, (string) $reflector->reflect()); -})->with('reflection_types'); - -it('will ignore a reflected type if it is already in the docblock', function (string $reflection, string $docbloc, string $outputType) { - $reflection = FakeReflectionProperty::create() - ->withType(FakeReflectionType::create()->withType($reflection)) - ->withDocComment($docbloc); - - $reflector = new PropertyTypeReflector($reflection); - - assertEquals($outputType, (string) $reflector->reflect()); -})->with('ignored_types'); - -it('can only use reflection property for typing', function () { - $reflection = FakeReflectionProperty::create()->withType( - FakeReflectionType::create()->withIsBuiltIn(true)->withType('string') - ); - - $reflector = new PropertyTypeReflector($reflection); - - assertEquals('string', (string) $reflector->reflect()); -}); - -it('can nullify types based upon reflection', function (string $docbloc, string $outputType) { - $reflection = FakeReflectionProperty::create()->withType( - FakeReflectionType::create()->withType('int')->withAllowsNull() - )->withDocComment("@var {$docbloc}"); - - $reflector = new PropertyTypeReflector($reflection); - - assertEquals($outputType, (string) $reflector->reflect()); -})->with('nullified_types'); - -it('can use an union type with reflection', function () { - $reflection = FakeReflectionProperty::create()->withType( - FakeReflectionUnionType::create()->withType( - FakeReflectionType::create()->withType('int')->withAllowsNull(), - FakeReflectionType::create()->withType('float'), - ) - ); - - $reflector = new PropertyTypeReflector($reflection); - - assertEquals('int|float|null', (string) $reflector->reflect()); -}); - -it('can use a transformable attribute as type', function () { - $class = new class() { - #[LiteralTypeScriptType('EnumType[]')] - public $literal; - }; - - $reflection = new ReflectionProperty($class, 'literal'); - - $reflector = new PropertyTypeReflector($reflection); - - assertEquals('EnumType[]', (string) $reflector->reflect()); -}); - -it('can reflect docblocks without a complete fsqen', function () { - assertEquals( - '\\' . Dto::class, - (string) PropertyTypeReflector::create(new ReflectionProperty(FakeAnnotationsClass::class, 'property'))->reflect() - ); - - assertEquals( - '\\' . Dto::class, - (string) PropertyTypeReflector::create(new ReflectionProperty(FakeAnnotationsClass::class, 'fsqnProperty'))->reflect() - ); - - assertEquals( - '\\' . Dto::class . '[]', - (string) PropertyTypeReflector::create(new ReflectionProperty(FakeAnnotationsClass::class, 'arrayProperty'))->reflect() - ); -}); diff --git a/tests/TypeReplacements.php b/tests/TypeReplacements.php new file mode 100644 index 0000000..8fc5384 --- /dev/null +++ b/tests/TypeReplacements.php @@ -0,0 +1,132 @@ +typesProvider(new InlineTypesProvider(TransformedFactory::alias( + 'date', + new TypeScriptObject([ + new TypeScriptProperty('datetime', new TypeReference(new ClassStringReference(DateTime::class))), + ]) + ))) + ->writer($writer = new MemoryWriter()) + ->replaceType(DateTime::class, $replacement) + ->get(); + + TypeScriptTransformer::create($config)->execute(); + + expect($writer->getTransformedNodeByName('date'))->toEqual(new TypeScriptAlias( + new TypeScriptIdentifier('date'), + $expected + )); +})->with(function () { + yield 'with a user defined PHP type' => [ + 'replacement' => 'string', + 'expected' => new TypeScriptObject([ + new TypeScriptProperty('datetime', new TypeScriptString()), + ]), + ]; + + yield 'with a user defined complex PHP type' => [ + 'replacement' => 'array{day: int, month: int, year: int}', + 'expected' => new TypeScriptObject([ + new TypeScriptProperty('datetime', new TypeScriptObject([ + new TypeScriptProperty('day', new TypeScriptNumber()), + new TypeScriptProperty('month', new TypeScriptNumber()), + new TypeScriptProperty('year', new TypeScriptNumber()), + ])), + ]), + ]; + + yield 'with a user defined type' => [ + 'replacement' => 'JsDate', + 'expected' => new TypeScriptObject([ + new TypeScriptProperty('datetime', new TypeScriptRaw('JsDate')), + ]), + ]; + + yield 'with a typescript node' => [ + 'replacement' => new TypeScriptObject([ + new TypeScriptProperty('date', new TypeScriptString()), + ]), + 'expected' => new TypeScriptObject([ + new TypeScriptProperty('datetime', new TypeScriptObject([ + new TypeScriptProperty('date', new TypeScriptString()), + ])), + ]), + ]; + + yield 'using a closure' => [ + 'replacement' => fn (TypeScriptNode $node) => new TypeScriptObject([ + new TypeScriptProperty('date', new TypeScriptString()), + ]), + 'expected' => new TypeScriptObject([ + new TypeScriptProperty('datetime', new TypeScriptObject([ + new TypeScriptProperty('date', new TypeScriptString()), + ])), + ]), + ]; +}); + +it('will replace inherited types', function () { + $config = TypeScriptTransformerConfigFactory::create() + ->typesProvider(new InlineTypesProvider(TransformedFactory::alias( + 'date', + new TypeScriptObject([ + new TypeScriptProperty('datetime', new TypeReference(new ClassStringReference(Carbon::class))), + ]) + ))) + ->writer($writer = new MemoryWriter()) + ->replaceType(DateTime::class, 'string') + ->get(); + + TypeScriptTransformer::create($config)->execute(); + + expect($writer->getTransformedNodeByName('date'))->toEqual(new TypeScriptAlias( + new TypeScriptIdentifier('date'), + new TypeScriptObject([ + new TypeScriptProperty('datetime', new TypeScriptString()), + ]) + )); +}); + +it('will replace implemented types', function () { + $config = TypeScriptTransformerConfigFactory::create() + ->typesProvider(new InlineTypesProvider(TransformedFactory::alias( + 'date', + new TypeScriptObject([ + new TypeScriptProperty('datetime', new TypeReference(new ClassStringReference(Carbon::class))), + ]) + ))) + ->writer($writer = new MemoryWriter()) + ->replaceType(DateTimeInterface::class, 'string') + ->get(); + + TypeScriptTransformer::create($config)->execute(); + + expect($writer->getTransformedNodeByName('date'))->toEqual(new TypeScriptAlias( + new TypeScriptIdentifier('date'), + new TypeScriptObject([ + new TypeScriptProperty('datetime', new TypeScriptString()), + ]) + )); +}); diff --git a/tests/TypeScript/TypeScriptEnumTest.php b/tests/TypeScript/TypeScriptEnumTest.php new file mode 100644 index 0000000..1201381 --- /dev/null +++ b/tests/TypeScript/TypeScriptEnumTest.php @@ -0,0 +1,70 @@ +write(new WritingContext(fn () => '')))->toBe($expected); +})->with(function () { + yield 'numeric enum without indexes' => [ + 'cases' => [ + ['name' => 'Up', 'value' => null], + ['name' => 'Down', 'value' => null], + ['name' => 'Left', 'value' => null], + ['name' => 'Right', 'value' => null], + ], + 'expected' => << [ + 'cases' => [ + ['name' => 'Up', 'value' => null], + ['name' => 'Down', 'value' => 3], + ['name' => 'Left', 'value' => null], + ['name' => 'Right', 'value' => null], + ], + 'expected' => << [ + 'cases' => [ + ['name' => 'Up', 'value' => 'up'], + ['name' => 'Down', 'value' => 'down'], + ['name' => 'Left', 'value' => 'left'], + ['name' => 'Right', 'value' => 'right'], + ], + 'expected' => <<transformers([ - MyclabsEnumTransformer::class, - ]); - - assertEquals([new MyclabsEnumTransformer($config)], $config->getTransformers()); -}); - -it('can create transformers with constructor', function () { - $config = TypeScriptTransformerConfig::create()->transformers([ - DtoTransformer::class, - ]); - - assertEquals([new DtoTransformer($config)], $config->getTransformers()); -}); - -it('will check if a class property replacement class exists', function () { - $this->expectException(InvalidDefaultTypeReplacer::class); - - $config = TypeScriptTransformerConfig::create()->defaultTypeReplacements([ - 'fake-class' => 'string', - ]); - - $config->getDefaultTypeReplacements(); -}); - -it('can use a php type in a class property replacer', function () { - $config = TypeScriptTransformerConfig::create()->defaultTypeReplacements([ - DateTime::class => 'array', - ]); - - assertEquals( - [DateTime::class => new Array_(new String_(), new String_())], - $config->getDefaultTypeReplacements() - ); -}); - -it('can use a typescript type in a class property replacer', function () { - $config = TypeScriptTransformerConfig::create()->defaultTypeReplacements([ - Dto::class => new TypeScriptType('any'), - ]); - - assertEquals( - [Dto::class => new TypeScriptType('any')], - $config->getDefaultTypeReplacements() - ); -}); - -it('can use a php dodumenter type in a class property replacer', function () { - $config = TypeScriptTransformerConfig::create()->defaultTypeReplacements([ - Dto::class => new String_(), - ]); - - assertEquals( - [Dto::class => new String_()], - $config->getDefaultTypeReplacements() - ); -}); diff --git a/tests/Types/RecordTypeTest.php b/tests/Types/RecordTypeTest.php deleted file mode 100644 index 776a856..0000000 --- a/tests/Types/RecordTypeTest.php +++ /dev/null @@ -1,46 +0,0 @@ -getKeyType()); - assertEquals(new Object_(new Fqsen('\\'.RegularEnum::class)), $record->getValueType()); -}); - -it('creates a scalar key and an struct value', function () { - $record = new RecordType('string', [ - 'enum' => RegularEnum::class, - 'array' => 'int[]', - ]); - - assertInstanceOf(RecordType::class, $record); - assertEquals(new String_(), $record->getKeyType()); - - assertInstanceOf(StructType::class, $record->getValueType()); - assertEquals([ - 'enum' => new Object_(new Fqsen('\\'.RegularEnum::class)), - 'array' => new Array_(new Integer()), - ], $record->getValueType()->getTypes()); -}); - -it('creates a scalar key and an array value', function () { - $record = new RecordType(RegularEnum::class, BackedEnumWithoutAnnotation::class, array: true); - - assertInstanceOf(RecordType::class, $record); - assertEquals(new Object_(new Fqsen('\\'.RegularEnum::class)), $record->getKeyType()); - assertEquals(new Array_(new Object_(new Fqsen('\\'.BackedEnumWithoutAnnotation::class))), $record->getValueType()); -}); diff --git a/tests/Types/StructTypeTest.php b/tests/Types/StructTypeTest.php deleted file mode 100644 index fcd9684..0000000 --- a/tests/Types/StructTypeTest.php +++ /dev/null @@ -1,38 +0,0 @@ - 'string', - 'a_float' => 'float', - 'a_class' => RegularEnum::class, - 'an_array' => 'int[]', - 'an_object' => [ - 'a_bool' => 'bool', - 'an_int' => 'int', - ], - ]); - - assertInstanceOf(StructType::class, $struct); - assertEquals([ - 'a_string' => new String_(), - 'a_float' => new Float_(), - 'a_class' => new Object_(new Fqsen('\\'.RegularEnum::class)), - 'an_array' => new Array_(new Integer()), - 'an_object' => new StructType([ - 'a_bool' => new Boolean(), - 'an_int' => new Integer(), - ]), - ], $struct->getTypes()); -}); diff --git a/tests/Visitor/VisitorTest.php b/tests/Visitor/VisitorTest.php new file mode 100644 index 0000000..7e030c0 --- /dev/null +++ b/tests/Visitor/VisitorTest.php @@ -0,0 +1,116 @@ +before(function (TypeScriptNode $node) use (&$baseNode, &$subNodes) { + if ($node instanceof TypeScriptUnion) { + $baseNode = $node; + } else { + $subNodes[] = $node; + } + }) + ->execute($unionNode); + + expect($visited)->toBe($unionNode); + expect($baseNode)->toBe($unionNode); + expect($subNodes)->toEqual([$stringNode, $numberNode]); +}); + +it('can change a single node', function () { + $unionNode = new TypeScriptUnion([ + $stringNode = new TypeScriptString(), + new TypeScriptNumber(), + ]); + + $visited = Visitor::create() + ->before(function (TypeScriptNode $node) use (&$baseNode) { + if ($node instanceof TypeScriptUnion) { + unset($node->types[1]); + } + }) + ->execute($unionNode); + + expect($visited)->toBe($unionNode); + expect($unionNode->types)->toEqual([$stringNode]); +}); + +it('can remove a single node in an iterateable', function () { + $unionNode = new TypeScriptUnion([ + $stringNode = new TypeScriptString(), + new TypeScriptNumber(), + ]); + + $visited = Visitor::create() + ->before(function (TypeScriptNode $node) { + if ($node instanceof TypeScriptNumber) { + return VisitorOperation::remove(); + } + }) + ->execute($unionNode); + + expect($visited)->toBe($unionNode); + expect($unionNode->types)->toEqual([$stringNode]); +}); + +it('can replace a single node in an iterateable', function () { + $unionNode = new TypeScriptUnion([ + $stringNode = new TypeScriptString(), + new TypeScriptNumber(), + ]); + + $visited = Visitor::create() + ->before(function (TypeScriptNode $node) { + if ($node instanceof TypeScriptNumber) { + return VisitorOperation::replace( + new TypeScriptBoolean(), + ); + } + }) + ->execute($unionNode); + + expect($visited)->toBe($unionNode); + expect($unionNode->types)->toEqual([$stringNode, new TypeScriptBoolean()]); +}); + +it('will execute a before and after closure correctly', function () { + $rootNode = new TypeScriptUnion([ + new TypeScriptString(), + new TypeScriptNumber(), + ]); + + $order = []; + + Visitor::create() + ->before(function (TypeScriptNode $node) use (&$order) { + $order[] = 'before '. $node::class; + }) + ->after(function (TypeScriptNode $node) use (&$order) { + $order[] = 'after '. $node::class; + }) + ->execute($rootNode); + + expect($order)->toEqual([ + 'before '. TypeScriptUnion::class, + 'before '. TypeScriptString::class, + 'after '. TypeScriptString::class, + 'before '. TypeScriptNumber::class, + 'after '. TypeScriptNumber::class, + 'after '. TypeScriptUnion::class, + ]); +}); diff --git a/tests/VisitorClosures.php b/tests/VisitorClosures.php new file mode 100644 index 0000000..9ff716e --- /dev/null +++ b/tests/VisitorClosures.php @@ -0,0 +1,51 @@ +typesProvider(new InlineTypesProvider(TransformedFactory::alias( + 'someObject', + new TypeScriptObject([ + new TypeScriptProperty('name', new TypeReference(new ClassStringReference(DateTime::class))), + ]) + ))) + ->writer($writer = new MemoryWriter()) + ->providedVisitorHook(function (TypeScriptObject $reference) { + return VisitorOperation::replace(new TypeScriptString()); + }, [TypeScriptObject::class]) + ->get(); + + TypeScriptTransformer::create($config)->execute(); + + expect($writer->getOutput())->toEqual('type someObject = string;'); +}); + +it('can run visitor closures when types are connected', function () { + $config = TypeScriptTransformerConfigFactory::create() + ->typesProvider(new InlineTypesProvider(TransformedFactory::alias( + 'someObject', + new TypeScriptObject([ + new TypeScriptProperty('name', new TypeReference(new ClassStringReference(DateTime::class))), + ]) + ))) + ->writer($writer = new MemoryWriter()) + ->connectedVisitorHook(function (TypeScriptObject $reference) { + return VisitorOperation::replace(new TypeScriptString()); + }, [TypeScriptObject::class]) + ->get(); + + TypeScriptTransformer::create($config)->execute(); + + expect($writer->getOutput())->toEqual('type someObject = string;'); +}); diff --git a/tests/Writers/FlatWriterTest.php b/tests/Writers/FlatWriterTest.php new file mode 100644 index 0000000..848e267 --- /dev/null +++ b/tests/Writers/FlatWriterTest.php @@ -0,0 +1,79 @@ +path = '/some/path'; + + $this->writer = new FlatWriter($this->path); +}); + + +it('can write everything in one flat file', function () { + $transformedCollection = new TransformedCollection([ + TransformedFactory::alias('RootType', new TypeScriptString())->build(), + TransformedFactory::alias('RootType2', new TypeScriptString())->build(), + TransformedFactory::alias('Level1Type', new TypeScriptString(), location: ['level1'])->build(), + TransformedFactory::alias('Level1Type2', new TypeScriptString(), location: ['level1'])->build(), + TransformedFactory::alias('Level2Type', new TypeScriptString(), location: ['level1', 'level2'])->build(), + ]); + + [$file] = $this->writer->output( + $transformedCollection, + ); + + expect($file) + ->toBeInstanceOf(WriteableFile::class) + ->path->toBe($this->path) + ->contents->toBe(<<build(), + TransformedFactory::alias('B', new TypeScriptString(), reference: $referenceB, location: ['nested', 'subNested'])->build(), + TransformedFactory::alias('C', new TypeScriptObject([ + new TypeScriptProperty('a', new TypeReference($referenceA)), + new TypeScriptProperty('b', new TypeReference($referenceB)), + ]))->build(), + ]); + + (new ConnectReferencesAction(TypeScriptTransformerLog::createNullLog()))->execute($transformedCollection); + + [$file] = $this->writer->output( + $transformedCollection, + ); + + expect($file) + ->toBeInstanceOf(WriteableFile::class) + ->path->toBe($this->path) + ->contents->toBe(<<path = '/some/path'; + + $this->writer = new ModuleWriter($this->path); +}); + +it('can write modules', function () { + $transformedCollection = new TransformedCollection([ + TransformedFactory::alias('RootType', new TypeScriptString())->build(), + TransformedFactory::alias('RootType2', new TypeScriptString())->build(), + TransformedFactory::alias('Level1Type', new TypeScriptString(), location: ['level1'])->build(), + TransformedFactory::alias('Level1Type2', new TypeScriptString(), location: ['level1'])->build(), + TransformedFactory::alias('Level2Type', new TypeScriptString(), location: ['level1', 'level2'])->build(), + ]); + + $files = $this->writer->output( + $transformedCollection, + ); + + expect($files) + ->toHaveCount(3) + ->each->toBeInstanceOf(WriteableFile::class); + + expect($files[0]) + ->path->toBe($this->path.'/index.ts') + ->contents->toBe('export type RootType = string;'.PHP_EOL.'export type RootType2 = string;'.PHP_EOL); + + expect($files[1]) + ->path->toBe($this->path.'/level1/index.ts') + ->contents->toBe('export type Level1Type = string;'.PHP_EOL.'export type Level1Type2 = string;'.PHP_EOL); + + expect($files[2]) + ->path->toBe($this->path.'/level1/level2/index.ts') + ->contents->toBe('export type Level2Type = string;'.PHP_EOL); +}); + +it('can define paths in different ways', function () { + $rootTransformed = TransformedFactory::alias('Type', new TypeScriptString())->build(); + $nestedTransformed = TransformedFactory::alias('Type', new TypeScriptString(), location: ['nested'])->build(); + + $withEndWriter = new ModuleWriter('/some-path/'); + $withoutEndWriter = new ModuleWriter('/some-path'); + + $transformedCollection = new TransformedCollection([$rootTransformed, $nestedTransformed]); + + $withEndFiles = $withEndWriter->output($transformedCollection); + $withoutEndFiles = $withoutEndWriter->output($transformedCollection); + + expect($withEndFiles)->toEqual($withoutEndFiles); +}); + +it('can reference other types within the module', function () { + $reference = new CustomReference('test', 'A'); + + $transformedCollection = new TransformedCollection([ + TransformedFactory::alias('A', new TypeScriptString(), reference: $reference)->build(), + TransformedFactory::alias('B', new TypeReference($reference))->build(), + ]); + + (new ConnectReferencesAction(TypeScriptTransformerLog::createNullLog()))->execute($transformedCollection); + + $files = $this->writer->output( + $transformedCollection, + ); + + expect($files) + ->toHaveCount(1) + ->each->toBeInstanceOf(WriteableFile::class); + + expect($files[0]) + ->path->toBe($this->path.'/index.ts') + ->contents->toBe('export type A = string;'.PHP_EOL.'export type B = A;'.PHP_EOL); +}); + +it('can reference other types within a nested module', function () { + $referenceA = new CustomReference('test', 'A'); + $referenceB = new CustomReference('test', 'B'); + + $transformedCollection = new TransformedCollection([ + TransformedFactory::alias('A', new TypeScriptString(), reference: $referenceA, location: ['nested'])->build(), + TransformedFactory::alias('B', new TypeScriptString(), reference: $referenceB, location: ['nested', 'subNested'])->build(), + TransformedFactory::alias('C', new TypeScriptObject([ + new TypeScriptProperty('a', new TypeReference($referenceA)), + new TypeScriptProperty('b', new TypeReference($referenceB)), + ]))->build(), + ]); + + (new ConnectReferencesAction(TypeScriptTransformerLog::createNullLog()))->execute($transformedCollection); + + $files = $this->writer->output( + $transformedCollection, + ); + + expect($files) + ->toHaveCount(3) + ->each->toBeInstanceOf(WriteableFile::class); + + expect($files[0]) + ->path->toBe($this->path.'/index.ts') + ->contents->toBe( + <<<'TypeScript' +import { A } from 'nested'; +import { B } from 'nested/subNested'; + +export type C = { +a: A +b: B +}; + +TypeScript + ); + + expect($files[1]) + ->path->toBe($this->path.'/nested/index.ts') + ->contents->toBe('export type A = string;'.PHP_EOL); + + expect($files[2]) + ->path->toBe($this->path.'/nested/subNested/index.ts') + ->contents->toBe('export type B = string;'.PHP_EOL); +}); + +it('can combine imports from nested modules', function () { + $referenceA = new CustomReference('test', 'A'); + $referenceB = new CustomReference('test', 'B'); + + $transformedCollection = new TransformedCollection([ + TransformedFactory::alias('A', new TypeScriptString(), reference: $referenceA, location: ['nested'])->build(), + TransformedFactory::alias('B', new TypeScriptString(), reference: $referenceB, location: ['nested'])->build(), + TransformedFactory::alias('C', new TypeScriptObject([ + new TypeScriptProperty('a', new TypeReference($referenceA)), + new TypeScriptProperty('b', new TypeReference($referenceB)), + ]))->build(), + ]); + + (new ConnectReferencesAction(TypeScriptTransformerLog::createNullLog()))->execute($transformedCollection); + + $files = $this->writer->output( + $transformedCollection, + ); + + expect($files) + ->toHaveCount(2) + ->each->toBeInstanceOf(WriteableFile::class); + + expect($files[0]) + ->path->toBe($this->path.'/index.ts') + ->contents->toBe( + <<<'TypeScript' +import { A, B } from 'nested'; + +export type C = { +a: A +b: B +}; + +TypeScript + ); + + expect($files[1]) + ->path->toBe($this->path.'/nested/index.ts') + ->contents->toBe('export type A = string;'.PHP_EOL.'export type B = string;'.PHP_EOL); +}); + +it('can import from root into a nested module', function () { + $reference = new CustomReference('test', 'A'); + + $transformedCollection = new TransformedCollection([ + TransformedFactory::alias('A', new TypeScriptString(), reference: $reference)->build(), + TransformedFactory::alias('B', new TypeReference($reference), location: ['nested'])->build(), + ]); + + (new ConnectReferencesAction(TypeScriptTransformerLog::createNullLog()))->execute($transformedCollection); + + $files = $this->writer->output( + $transformedCollection, + ); + + expect($files) + ->toHaveCount(2) + ->each->toBeInstanceOf(WriteableFile::class); + + expect($files[0]) + ->path->toBe($this->path.'/index.ts') + ->contents->toBe('export type A = string;'.PHP_EOL); + + expect($files[1]) + ->path->toBe($this->path.'/nested/index.ts') + ->contents->toBe(<<<'TypeScript' +import { A } from '../'; + +export type B = A; + +TypeScript); +}); + +it('can automatically alias imported types', function () { + $reference = new CustomReference('test', 'A'); + + $transformedCollection = new TransformedCollection([ + TransformedFactory::alias('A', new TypeScriptString(), reference: $reference)->build(), + TransformedFactory::alias('A', new TypeReference($reference), location: ['nested'])->build(), + ]); + + (new ConnectReferencesAction(TypeScriptTransformerLog::createNullLog()))->execute($transformedCollection); + + $files = $this->writer->output( + $transformedCollection, + ); + + expect($files) + ->toHaveCount(2) + ->each->toBeInstanceOf(WriteableFile::class); + + expect($files[0]) + ->path->toBe($this->path.'/index.ts') + ->contents->toBe('export type A = string;'.PHP_EOL); + + expect($files[1]) + ->path->toBe($this->path.'/nested/index.ts') + ->contents->toBe(<<<'TypeScript' +import { A as AImport } from '../'; + +export type A = AImport; + +TypeScript); +}); diff --git a/tests/Writers/NamespaceWriterTest.php b/tests/Writers/NamespaceWriterTest.php new file mode 100644 index 0000000..98e41b6 --- /dev/null +++ b/tests/Writers/NamespaceWriterTest.php @@ -0,0 +1,97 @@ +build(), + TransformedFactory::alias('RootType2', new TypeScriptString())->build(), + TransformedFactory::alias('Level1Type', new TypeScriptString(), location: ['level1'])->build(), + TransformedFactory::alias('Level1Type2', new TypeScriptString(), location: ['level1'])->build(), + TransformedFactory::alias('Level2Type', new TypeScriptString(), location: ['level1', 'level2'])->build(), + ]); + + $filename = 'types.ts'; + + $files = (new NamespaceWriter($filename))->output( + $transformedCollection, + ); + + expect($files) + ->toHaveCount(1) + ->each->toBeInstanceOf(WriteableFile::class); + + $file = $files[0]; + + expect($file) + ->path->toBe($filename) + ->contents->toEqual( + <<build(), + TransformedFactory::alias('B', new TypeScriptString(), reference: $referenceB, location: ['nested', 'subNested'])->build(), + TransformedFactory::alias('C', new TypeScriptObject([ + new TypeScriptProperty('a', new TypeReference($referenceA)), + new TypeScriptProperty('b', new TypeReference($referenceB)), + ]))->build(), + ]); + + $filename = 'types.ts'; + + (new ConnectReferencesAction(TypeScriptTransformerLog::createNullLog()))->execute($transformedCollection); + + $files = (new NamespaceWriter($filename))->output( + $transformedCollection, + ); + + expect($files) + ->toHaveCount(1) + ->each->toBeInstanceOf(WriteableFile::class); + + $file = $files[0]; + + expect($file) + ->path->toBe($filename) + ->contents->toEqual(<<; -none: any; -documented_string: string; -mixed: number | string; -number: number; -documented_array: Array; -mixed_with_array: number | string | Array; -array_with_null: Array; -enum: {%Spatie\TypeScriptTransformer\Tests\FakeClasses\Integration\Enum%}; -non_typescripted_type: {%Spatie\TypeScriptTransformer\Tests\FakeClasses\Enum\RegularEnum%}; -other_dto: {%Spatie\TypeScriptTransformer\Tests\FakeClasses\Integration\OtherDto%}; -other_dto_array: Array<{%Spatie\TypeScriptTransformer\Tests\FakeClasses\Integration\OtherDto%}>; -other_dto_collection: Array<{%Spatie\TypeScriptTransformer\Tests\FakeClasses\Integration\OtherDto%}>; -dto_with_children: {%Spatie\TypeScriptTransformer\Tests\FakeClasses\Integration\DtoWithChildren%}; -another_namespace_dto: {%Spatie\TypeScriptTransformer\Tests\FakeClasses\Integration\LevelUp\YetAnotherDto%}; -nullable_string: string | number | null; -reflection_replaced_default_type: string; -docblock_replaced_default_type: string; -array_replaced_default_type: Array; -array_as_object: { [key: string]: any }; -} \ No newline at end of file diff --git a/tests/__snapshots__/DtoTransformerTest__it_will_take_transform_as_typescript_attributes_into_account__1.txt b/tests/__snapshots__/DtoTransformerTest__it_will_take_transform_as_typescript_attributes_into_account__1.txt deleted file mode 100644 index a2a0322..0000000 --- a/tests/__snapshots__/DtoTransformerTest__it_will_take_transform_as_typescript_attributes_into_account__1.txt +++ /dev/null @@ -1,8 +0,0 @@ -{ -int: number; -overwritable: number | boolean; -object: {an_int:number;a_bool:boolean;}; -pure_typescript: never; -pure_typescript_object: {an_any:any;a_never:never;}; -regular_type: number; -} \ No newline at end of file diff --git a/tests/__snapshots__/FormatFilesActionTest__it_can_disable_formatting__1.txt b/tests/__snapshots__/FormatFilesActionTest__it_can_disable_formatting__1.txt new file mode 100644 index 0000000..c886afd --- /dev/null +++ b/tests/__snapshots__/FormatFilesActionTest__it_can_disable_formatting__1.txt @@ -0,0 +1 @@ +{int: number;overwritable: number | boolean;object: {an_int:number;a_bool:boolean;}pure_typescript: never;pure_typescript_object: {an_any:any;a_never:never;}regular_type: number;} \ No newline at end of file diff --git a/tests/__snapshots__/FormatFilesActionTest__it_can_disable_formatting__2.txt b/tests/__snapshots__/FormatFilesActionTest__it_can_disable_formatting__2.txt new file mode 100644 index 0000000..c886afd --- /dev/null +++ b/tests/__snapshots__/FormatFilesActionTest__it_can_disable_formatting__2.txt @@ -0,0 +1 @@ +{int: number;overwritable: number | boolean;object: {an_int:number;a_bool:boolean;}pure_typescript: never;pure_typescript_object: {an_any:any;a_never:never;}regular_type: number;} \ No newline at end of file diff --git a/tests/__snapshots__/FormatFilesActionTest__it_can_format_an_generated_file_with_prettier__1.txt b/tests/__snapshots__/FormatFilesActionTest__it_can_format_an_generated_file_with_prettier__1.txt new file mode 100644 index 0000000..2b002f8 --- /dev/null +++ b/tests/__snapshots__/FormatFilesActionTest__it_can_format_an_generated_file_with_prettier__1.txt @@ -0,0 +1,14 @@ +{ + int: number; + overwritable: number | boolean; + object: { + an_int: number; + a_bool: boolean; + } + pure_typescript: never; + pure_typescript_object: { + an_any: any; + a_never: never; + } + regular_type: number; +} diff --git a/tests/__snapshots__/FormatFilesActionTest__it_can_format_an_generated_file_with_prettier__2.txt b/tests/__snapshots__/FormatFilesActionTest__it_can_format_an_generated_file_with_prettier__2.txt new file mode 100644 index 0000000..2b002f8 --- /dev/null +++ b/tests/__snapshots__/FormatFilesActionTest__it_can_format_an_generated_file_with_prettier__2.txt @@ -0,0 +1,14 @@ +{ + int: number; + overwritable: number | boolean; + object: { + an_int: number; + a_bool: boolean; + } + pure_typescript: never; + pure_typescript_object: { + an_any: any; + a_never: never; + } + regular_type: number; +} diff --git a/tests/__snapshots__/IntegrationTest__it_can_transform_to_es_modules__1.txt b/tests/__snapshots__/IntegrationTest__it_can_transform_to_es_modules__1.txt deleted file mode 100644 index 1488b8e..0000000 --- a/tests/__snapshots__/IntegrationTest__it_can_transform_to_es_modules__1.txt +++ /dev/null @@ -1,43 +0,0 @@ -export type Dto = { -string: string; -nullbable: string | null; -default: string; -int: number; -boolean: boolean; -float: number; -object: object; -array: Array; -none: any; -documented_string: string; -mixed: number | string; -number: number; -documented_array: Array; -mixed_with_array: number | string | Array; -array_with_null: Array; -enum: Enum; -non_typescripted_type: any; -other_dto: OtherDto; -other_dto_array: Array; -other_dto_collection: Array; -dto_with_children: DtoWithChildren; -another_namespace_dto: YetAnotherDto; -nullable_string: string | number | null; -reflection_replaced_default_type: string; -docblock_replaced_default_type: string; -array_replaced_default_type: Array; -array_as_object: { [key: string]: any }; -}; -export type DtoWithChildren = { -name: string; -other_dto: OtherDto; -other_dto_array: Array; -}; -export type Enum = 'yes' | 'no'; -export type OtherDto = { -name: string; -}; -export type OtherDtoCollection = { -}; -export type YetAnotherDto = { -name: string; -}; diff --git a/tests/__snapshots__/IntegrationTest__it_works__1.txt b/tests/__snapshots__/IntegrationTest__it_works__1.txt deleted file mode 100644 index f6f0669..0000000 --- a/tests/__snapshots__/IntegrationTest__it_works__1.txt +++ /dev/null @@ -1,47 +0,0 @@ -declare namespace Spatie.TypeScriptTransformer.Tests.FakeClasses.Integration { -export type Dto = { -string: string; -nullbable: string | null; -default: string; -int: number; -boolean: boolean; -float: number; -object: object; -array: Array; -none: any; -documented_string: string; -mixed: number | string; -number: number; -documented_array: Array; -mixed_with_array: number | string | Array; -array_with_null: Array; -enum: Spatie.TypeScriptTransformer.Tests.FakeClasses.Integration.Enum; -non_typescripted_type: any; -other_dto: Spatie.TypeScriptTransformer.Tests.FakeClasses.Integration.OtherDto; -other_dto_array: Array; -other_dto_collection: Array; -dto_with_children: Spatie.TypeScriptTransformer.Tests.FakeClasses.Integration.DtoWithChildren; -another_namespace_dto: Spatie.TypeScriptTransformer.Tests.FakeClasses.Integration.LevelUp.YetAnotherDto; -nullable_string: string | number | null; -reflection_replaced_default_type: string; -docblock_replaced_default_type: string; -array_replaced_default_type: Array; -array_as_object: { [key: string]: any }; -}; -export type DtoWithChildren = { -name: string; -other_dto: Spatie.TypeScriptTransformer.Tests.FakeClasses.Integration.OtherDto; -other_dto_array: Array; -}; -export type Enum = 'yes' | 'no'; -export type OtherDto = { -name: string; -}; -export type OtherDtoCollection = { -}; -} -declare namespace Spatie.TypeScriptTransformer.Tests.FakeClasses.Integration.LevelUp { -export type YetAnotherDto = { -name: string; -}; -} diff --git a/tests/__snapshots__/InterfaceTransformerTest__it_transforms_methods_in_interfaces__1.txt b/tests/__snapshots__/InterfaceTransformerTest__it_transforms_methods_in_interfaces__1.txt new file mode 100644 index 0000000..c144329 --- /dev/null +++ b/tests/__snapshots__/InterfaceTransformerTest__it_transforms_methods_in_interfaces__1.txt @@ -0,0 +1,8 @@ +export interface SimpleInterface { +withoutParametersAndReturnType(): void; +withReturnType(): string; +withAnnotatedReturnType(): [string]; +withParameters(param1: string, param2: number): void; +withOptionalParameters(param1: string, param2?: number): void; +withAnnotatedParameters(param1: [string], param2: [boolean]): void; +} diff --git a/tests/__snapshots__/InterfaceTransformerTest__it_will_replace_methods__1.txt b/tests/__snapshots__/InterfaceTransformerTest__it_will_replace_methods__1.txt deleted file mode 100644 index f7072e8..0000000 --- a/tests/__snapshots__/InterfaceTransformerTest__it_will_replace_methods__1.txt +++ /dev/null @@ -1,4 +0,0 @@ -{ -testFunction(input: string, output: Array): number; -anotherTestFunction(): boolean; -} \ No newline at end of file diff --git a/tests/__snapshots__/LiteralTypeScriptTypeTest__it_can_output_a_single_type__1.txt b/tests/__snapshots__/LiteralTypeScriptTypeTest__it_can_output_a_single_type__1.txt new file mode 100644 index 0000000..4ecc92f --- /dev/null +++ b/tests/__snapshots__/LiteralTypeScriptTypeTest__it_can_output_a_single_type__1.txt @@ -0,0 +1,3 @@ +export type TestSingleLiteralTypeScriptTypeAttribute = { +property: Array<{label: string, value: string}> +}; diff --git a/tests/__snapshots__/LiteralTypeScriptTypeTest__it_can_output_an_object_type__1.txt b/tests/__snapshots__/LiteralTypeScriptTypeTest__it_can_output_an_object_type__1.txt new file mode 100644 index 0000000..530d035 --- /dev/null +++ b/tests/__snapshots__/LiteralTypeScriptTypeTest__it_can_output_an_object_type__1.txt @@ -0,0 +1,6 @@ +export type TestObjectLiteralTypeScriptTypeAttribute = { +property: { +label: string +value: string +} +}; diff --git a/tests/__snapshots__/TranspileTypeToTypeScriptActionTest__it_can_resolve_a_struct_type__1.txt b/tests/__snapshots__/TranspileTypeToTypeScriptActionTest__it_can_resolve_a_struct_type__1.txt deleted file mode 100644 index d15eb55..0000000 --- a/tests/__snapshots__/TranspileTypeToTypeScriptActionTest__it_can_resolve_a_struct_type__1.txt +++ /dev/null @@ -1 +0,0 @@ -{a_string:string;a_float:number;a_class:{%Spatie\TypeScriptTransformer\Tests\FakeClasses\Enum\RegularEnum%};an_array:Array;a_self_reference:{%fake_class%};an_object:{a_bool:boolean;an_int:number;};} \ No newline at end of file diff --git a/tests/__snapshots__/TypeScriptTypeTest__it_can_output_a_single_type__1.txt b/tests/__snapshots__/TypeScriptTypeTest__it_can_output_a_single_type__1.txt new file mode 100644 index 0000000..c8b9c6a --- /dev/null +++ b/tests/__snapshots__/TypeScriptTypeTest__it_can_output_a_single_type__1.txt @@ -0,0 +1,7 @@ +export type WriteableFile = { +path: string +contents: string +}; +export type TestSingleTypeScriptTypeAttribute = { +property: [WriteableFile] +}; diff --git a/tests/__snapshots__/TypeScriptTypeTest__it_can_output_an_object_type__1.txt b/tests/__snapshots__/TypeScriptTypeTest__it_can_output_an_object_type__1.txt new file mode 100644 index 0000000..8474374 --- /dev/null +++ b/tests/__snapshots__/TypeScriptTypeTest__it_can_output_an_object_type__1.txt @@ -0,0 +1,10 @@ +export type WriteableFile = { +path: string +contents: string +}; +export type TestObjectTypeScriptTypeAttribute = { +property: { +name: string +file: WriteableFile +} +}; diff --git a/tests/__snapshots__/files/FormatTypeScriptActionTest__it_can_disable_formatting__1.ts b/tests/__snapshots__/files/FormatTypeScriptActionTest__it_can_disable_formatting__1.ts deleted file mode 100644 index 0144c8c..0000000 --- a/tests/__snapshots__/files/FormatTypeScriptActionTest__it_can_disable_formatting__1.ts +++ /dev/null @@ -1 +0,0 @@ -export type Enum='yes'|'no';export type OtherDto={name:string} \ No newline at end of file diff --git a/tests/__snapshots__/files/Integration__it_can_handle_the_integration_test_with_a_flat_file__1.ts b/tests/__snapshots__/files/Integration__it_can_handle_the_integration_test_with_a_flat_file__1.ts new file mode 100644 index 0000000..aaedb67 --- /dev/null +++ b/tests/__snapshots__/files/Integration__it_can_handle_the_integration_test_with_a_flat_file__1.ts @@ -0,0 +1,39 @@ +export type IntegrationClass = { +string: string +nullable: string | null +default: string +int: number +boolean: boolean +float: number +object: object +array: [] +mixed: any +none: unknown +var_annotated: string +union: number | string +annotated_array: Array +complex_annotated_array: { +int: number +string: string +level_up: LevelUpClass +} +complex_union: number | string | Array +enum: Enum +non_typescript_type: undefined +array_of_reference: Array +replacement_type: string +annotated_replacement_type: string +array_annotated_replacement_type: Array +level_up_class: LevelUpClass +readonly readonly: string +optional?: string +constructor_annotated_array: Array +constructor_inline_annotated_array: Array +}; +export type Enum = "yes" | "no"; +export type IntegrationItem = { +name: string +}; +export type LevelUpClass = { +name: string +}; diff --git a/tests/__snapshots__/files/Integration__it_can_handle_the_integration_test_with_a_module_structure__1.ts b/tests/__snapshots__/files/Integration__it_can_handle_the_integration_test_with_a_module_structure__1.ts new file mode 100644 index 0000000..4e822f1 --- /dev/null +++ b/tests/__snapshots__/files/Integration__it_can_handle_the_integration_test_with_a_module_structure__1.ts @@ -0,0 +1,38 @@ +import { LevelUpClass } from 'Level'; + +export type Enum = "yes" | "no"; +export type IntegrationClass = { +string: string +nullable: string | null +default: string +int: number +boolean: boolean +float: number +object: object +array: [] +mixed: any +none: unknown +var_annotated: string +union: number | string +annotated_array: Array +complex_annotated_array: { +int: number +string: string +level_up: LevelUpClass +} +complex_union: number | string | Array +enum: Enum +non_typescript_type: undefined +array_of_reference: Array +replacement_type: string +annotated_replacement_type: string +array_annotated_replacement_type: Array +level_up_class: LevelUpClass +readonly readonly: string +optional?: string +constructor_annotated_array: Array +constructor_inline_annotated_array: Array +}; +export type IntegrationItem = { +name: string +}; diff --git a/tests/__snapshots__/files/Integration__it_can_handle_the_integration_test_with_a_module_structure__2.ts b/tests/__snapshots__/files/Integration__it_can_handle_the_integration_test_with_a_module_structure__2.ts new file mode 100644 index 0000000..2e3ce50 --- /dev/null +++ b/tests/__snapshots__/files/Integration__it_can_handle_the_integration_test_with_a_module_structure__2.ts @@ -0,0 +1,3 @@ +export type LevelUpClass = { +name: string +}; diff --git a/tests/__snapshots__/files/Integration__it_can_handle_the_integration_test_with_a_namespaced_file__1.ts b/tests/__snapshots__/files/Integration__it_can_handle_the_integration_test_with_a_namespaced_file__1.ts new file mode 100644 index 0000000..0b2aaba --- /dev/null +++ b/tests/__snapshots__/files/Integration__it_can_handle_the_integration_test_with_a_namespaced_file__1.ts @@ -0,0 +1,43 @@ +declare namespace Spatie.TypeScriptTransformer.Tests.Fakes.Integration{ +export type Enum = "yes" | "no"; +export type IntegrationClass = { +string: string +nullable: string | null +default: string +int: number +boolean: boolean +float: number +object: object +array: [] +mixed: any +none: unknown +var_annotated: string +union: number | string +annotated_array: Array +complex_annotated_array: { +int: number +string: string +level_up: Spatie.TypeScriptTransformer.Tests.Fakes.Integration.Level.LevelUpClass +} +complex_union: number | string | Array +enum: Spatie.TypeScriptTransformer.Tests.Fakes.Integration.Enum +non_typescript_type: undefined +array_of_reference: Array +replacement_type: string +annotated_replacement_type: string +array_annotated_replacement_type: Array +level_up_class: Spatie.TypeScriptTransformer.Tests.Fakes.Integration.Level.LevelUpClass +readonly readonly: string +optional?: string +constructor_annotated_array: Array +constructor_inline_annotated_array: Array +}; +export type IntegrationItem = { +name: string +}; +} +declare namespace Spatie.TypeScriptTransformer.Tests.Fakes.Integration.Level{ +export type LevelUpClass = { +name: string +}; +} diff --git a/tests/__snapshots__/files/PersistTypesCollectionActionTest__it_can_persist_multiple_types_in_one_namespace__1.ts b/tests/__snapshots__/files/PersistTypesCollectionActionTest__it_can_persist_multiple_types_in_one_namespace__1.ts deleted file mode 100644 index 8156868..0000000 --- a/tests/__snapshots__/files/PersistTypesCollectionActionTest__it_can_persist_multiple_types_in_one_namespace__1.ts +++ /dev/null @@ -1,6 +0,0 @@ -declare namespace test { -export type Enum = transformed test\Enum; -export type OtherEnum = transformed test\OtherEnum; -} -export type Enum = transformed Enum; -export type OtherEnum = transformed OtherEnum; \ No newline at end of file diff --git a/tests/__snapshots__/files/PersistTypesCollectionActionTest__it_can_re_save_the_file__1.ts b/tests/__snapshots__/files/PersistTypesCollectionActionTest__it_can_re_save_the_file__1.ts deleted file mode 100644 index 039e88c..0000000 --- a/tests/__snapshots__/files/PersistTypesCollectionActionTest__it_can_re_save_the_file__1.ts +++ /dev/null @@ -1,4 +0,0 @@ -declare namespace test { -export type Enum = fake-transformed; -} -export type Enum = fake-transformed; \ No newline at end of file diff --git a/tests/__snapshots__/files/PersistTypesCollectionActionTest__it_will_persist_the_types__1.ts b/tests/__snapshots__/files/PersistTypesCollectionActionTest__it_will_persist_the_types__1.ts deleted file mode 100644 index 8fe4cf8..0000000 --- a/tests/__snapshots__/files/PersistTypesCollectionActionTest__it_will_persist_the_types__1.ts +++ /dev/null @@ -1,7 +0,0 @@ -declare namespace test { -export type Enum = fake-transformed; -} -declare namespace test.test { -export type Enum = fake-transformed; -} -export type Enum = fake-transformed; \ No newline at end of file