tempest-framework icon indicating copy to clipboard operation
tempest-framework copied to clipboard

[Feature Request]: make: commands

Open brendt opened this issue 1 year ago • 19 comments

A code generator that takes into account the user's preferences. One concrete example is make:migration, see discussion at https://github.com/tempestphp/tempest-framework/pull/464

brendt avatar Sep 24 '24 11:09 brendt

Something to think about, a good way to generate classes where we actually want them. It's a hard problem to tackle; Laravel does it wrong in my opinion (tried to work around it here), but I'm sure Tempest could have a clean way of handling that.

innocenzi avatar Sep 24 '24 18:09 innocenzi

I think @wulfheart is on to something with nette/php-generator. I've used it in the past and it's a joy to use.

aidan-casey avatar Sep 24 '24 18:09 aidan-casey

I agree, especially with how Tempest doesn't require you to follow a fixed structure, it's a feature that shouldn't be taken lightly. I'm a total noob with code generation, so I appreciate all the input people can give. Let's actually widen the scope of this issue and use it as a starting point for a proper code generator.

brendt avatar Sep 24 '24 18:09 brendt

Side note: one time I built a code generator using first Twig and then nette/php-generator to create an API client for 280 endpoints after some guy on the internet, who now tells me he's a noob with code generation, told me that it would probably be the best approach.

aidan-casey avatar Sep 24 '24 18:09 aidan-casey

Hi there 👋 To be sure to understand everything. Now we have a tempest/generation package that helps generate php code easily from the ClassManipulator class. Then is the vision for the future is to take an opinion about a way to give users access to something like an attribute #[AsGeneratorCommand] with some parameters to use the ClassManipulator under the hood ? We could also have some basic generator class in the CLI to build classes like Commands, Controllers, Models, Views and so on based on stub files which can be overriden. Then it will be easier to let user create its own generation command based on custom stub files aswell.

gturpin-dev avatar Oct 05 '24 09:10 gturpin-dev

@gturpin-dev I see it that way:

  • If possible, have a high-level #[GeneratorCommand] that makes it super easy to create a generator command—the underlying method could return a ClassManipulator or ClassGenerator instance

  • Have some kind of lower-level tool (abstract class?) for more advanced use cases

  • Have built-in make: commands for classes, models, controllers, migration files, and everything that could make sense in Tempest

  • Ideally, the make: commands would behave just like tempest publish (as of #513) and would prompt the path to the file, defaulting to their main namespace.

innocenzi avatar Oct 05 '24 12:10 innocenzi

I totally agree with all of this.

Thinking about how to implement a first step of #[GeneratorConsoleCommand] because at the moment a classic command can be made on a class method itself. Maybe we can stick as close as possible with that giving all Attributes parameters of the #[ConsoleCommand] to customize the command and an additional parameter "replacements" which can be an array of slug to replace in the stub file as array keys and the replacements as values, but I think we must find another approach because the replacements values will mostly comes from the user inputs. Then should we force the return type of the methods ? Like the actual ExitCode for classic commands.

Same questions for other params like path to the stub file, output file path and maybe others.

As an alternative we could use this Attribute only for classes but I think it's too far away from the Tempest philosophy.

Another question, should we rely on some third-party packages to handle replacements ? Same goes for the file handling, or may we use the internal Filesystem ?

gturpin-dev avatar Oct 05 '24 17:10 gturpin-dev

@innocenzi I must complete my above questions, I dig deeper into nette's package and I think it can give us all we need to copy stubs, or manipulate classes ( or other types ) as you mention. I think we can start with some file_get_contents but progressively use the internal filesystem component.

gturpin-dev avatar Oct 05 '24 17:10 gturpin-dev

Just throwing something different in the mix for inspiration: how PlopJS is helping with JS code generation in my frontend projects is something I really appreciate.

https://plopjs.com/

Jeroen-G avatar Oct 06 '24 10:10 Jeroen-G

Thanks @Jeroen-G for inspiration, it's really interesting. It could help a lot if you can share some various real use cases you have with PlopJs.

We can imagine a method tagged with a #[GeneratorCommand] should return something like a FileGenerator instance which can have some arguments like a list of prompts and actions as in PlopJs. This class generator could even be a Fluent Interface to help build prompts and actions faster.

Still thinking about all of this. I think the easiest way to start is to make the commands for builtins features, I will try some things about it.

gturpin-dev avatar Oct 06 '24 21:10 gturpin-dev

Here is one of my plopfiles:

Details

import { NodePlopAPI } from "plop";

export default (plop: NodePlopAPI): void => {
  plop.setGenerator("component", {
    description: "Create a reusable component",
    prompts: [
      {
        type: "input",
        name: "name",
        message: "What is your component name?",
      },
      {
        type: "confirm",
        name: "hasFolder",
        message: "Does your component reside in a subfolder?",
      },
      {
        type: "input",
        name: "folder",
        message: "What is your component's subfolder name?",
        when: (answers) => !!answers.hasFolder,
      },
    ],
    actions: [
      {
        type: "add",
        path: "src/components{{#if folder}}/{{folder}}{{/if}}/{{pascalCase name}}/{{pascalCase name}}.tsx",
        templateFile: "dev/templates/component/Component.tsx.hbs",
      },
      {
        type: "add",
        path: "src/components{{#if folder}}/{{folder}}{{/if}}/{{pascalCase name}}/index.ts",
        templateFile: "dev/templates/component/index.ts.hbs",
      },
    ],
  });

  plop.setGenerator("element", {
    description: "Create a reusable UI element",
    prompts: [
      {
        type: "input",
        name: "name",
        message: "What is your element name?",
      },
      {
        type: "confirm",
        name: "hasFolder",
        message: "Does your element reside in a subfolder?",
      },
      {
        type: "input",
        name: "folder",
        message: "What is your element's subfolder name?",
        when: (answers) => !!answers.hasFolder,
      },
    ],
    actions: [
      {
        type: "add",
        path: "src/elements{{#if folder}}/{{folder}}{{/if}}/{{pascalCase name}}/{{pascalCase name}}.tsx",
        templateFile: "dev/templates/element/Element.tsx.hbs",
      },
    ],
  });

  plop.setGenerator("screen", {
    description: "Create a screen",
    prompts: [
      {
        type: "input",
        name: "name",
        message: "What is your screen's name?",
      },
    ],
    actions: [
      {
        type: "add",
        path: "src/app/{{lowerCase name}}/index.tsx",
        templateFile: "dev/templates/screen/index.tsx.hbs",
      },
    ],
  });

  plop.setGenerator("hook", {
    description: "Create a React hook",
    prompts: [
      {
        type: "input",
        name: "name",
        message: "What is your hook's name?",
        filter: (value) =>
          value.startsWith("use")
            ? value
            : `use${value.charAt(0).toUpperCase()}${value.slice(1)}`,
      },
    ],
    actions: [
      {
        type: "add",
        path: "src/hooks/{{camelCase name}}.ts",
        templateFile: "dev/templates/hook/hook.ts.hbs",
      },
      {
        type: "add",
        path: "src/hooks/{{camelCase name}}.test.ts",
        templateFile: "dev/templates/hook/hook.test.ts.hbs",
      },
    ],
  });
};

And this could be an example of an implementation in PHP:

Details
<?php

namespace App;

use Tempest\Console\ConsoleCommand;

final class AppMakeCommand implements \Tempest\Console\MakeCommandInterface
{
    use IsMakeCommand;

    public function configure(MakeConfiguration $make): MakeConfiguration
    {
        return $make->generator('controller')
            ->description('Create a new controller')
            ->add('dev/templates/controller.php', 'src/{{module}}/Controllers/{{name}}.php')
            ->edit('src/Config/MyAppConfig.php', function(resource $file, array $properties) {
                // some weird search and replace can happen here.
                // $properties are the results of the prompts.
                return $file;
            })
            ->prompt([
                'module' => 'In which module should the controller be placed?',
                'name' => 'What is the name of the new controller?',
            ]);
    }
}


You would run for example ./tempest make controller The variables such as module and name would also be replaced in the added file, the edit action is for existing files. In fact, maybe the whole tempest templating engine might be used!

Jeroen-G avatar Oct 07 '24 14:10 Jeroen-G

I kinda like this approach, for userland to write their own make commands 👀

innocenzi avatar Oct 07 '24 17:10 innocenzi

I like it too in some ways. Thanks @Jeroen-G

@innocenzi Is the fact of having an Abstract/Trait to help users to make their own generator commands a bad thing for Tempest rather than an attribute ?

I personnally don't find a way of getting all utilities/flexibility ( like closures ) using attributes. I think we must go with an abstract/trait to achieve this.

Then @innocenzi do you think we should go with an interface/trait combo or an abstract class to be consistent with the rest of the framework ?

gturpin-dev avatar Oct 07 '24 17:10 gturpin-dev

AFAIK the framework largely works with interface/trait, not abstract classes (e.g. the DB models)

Jeroen-G avatar Oct 07 '24 17:10 Jeroen-G

AFAIK the framework largely works with interface/trait, not abstract classes (e.g. the DB models)

Correct, see https://www.youtube.com/watch?v=HK9W5A-Doxc for more info :)

brendt avatar Oct 08 '24 05:10 brendt

Yes, I saw the video yesterday !

And yes all the components I can see in the docs works like this and it's totally fine ( like the explanations in the video )

gturpin-dev avatar Oct 08 '24 05:10 gturpin-dev

Hey, to validate a MVP of the GeneratorCommand I firstly tried to create a make:controller command which will generate a Controller based on user inputs and stored in user project. I actually run into some questions here :

I've created a ControllerStub.php file to store the stub class as it will be easier to get from the ClassManipulator. So I've used the ClassManipulator to update the namespace and the classname ( new method ) and then put the file into a hardcoded folder for now. The interesting thing is the use statements have been altered during the process. To show you, here's my actual stubfile :

<?php

declare(strict_types=1);

namespace Tempest\Console\Stubs;

use Tempest\Http\Get;
use Tempest\View\View;

use function Tempest\view;

final class ControllerStub
{
    #[Get(uri: '/welcome')]
    public function __invoke(): View
    {
        return view('welcome');
    }
}

And here's the updated code :

<?php

namespace App\Controllers;

use Tempest\View\View as View1;
use Tempest\view;

final class WelcomeController
{
    #[\Tempest\Http\Get(uri: '/welcome')]
    public function __invoke(): View1
    {
        return \Tempest\view('welcome');
    }
}

So as you can see, the use function seems to be misunderstood from the nette/generator, also it don't like the view function and the View class as return type so he added an alias. If anyone have some explanations on it, I would appreciate.

My testing code is the following :

$classManipulator = (new ClassManipulator(ControllerStub::class))
    ->updateNamespace('App\\Controllers')
    ->updateClassName('WelcomeController');

file_put_contents(
    'src/WelcomeController.php',
    $classManipulator->print()
);
  • Then, the following points I'm looking for is the ability of the str() helper ( or another component ? ) to help manipulate path strings, CamelCase, snake_case etc
  • Also, I know the Filesystem is in WIP atm so I don't know if we should use it later for example to recursively create the path and the file from the user input ( eg make:controller Controllers/WelcomeController will generate a project_path/Controllers/WelcomeController.php without errors or additional questions )
  • Lastly, I wonder about how to get the main autoloaded namespace from the user, I know how to parse a composer.json, but i've seen that it's already done by the discovery process and I wonder if we can have this information without additional work or no 🤷‍♂️

I'll continue to work on this in the followings days, but if someone have responses about my thoughts, please let me know !

-- I will extract logic later when I started to be more confident with the first working internal generator I'm really in early stage of digging atm

gturpin-dev avatar Oct 09 '24 17:10 gturpin-dev

@gturpin-dev I fixed most of the issues in https://github.com/tempestphp/tempest-framework/pull/544, let me know if you are looking for something else.

@gturpin-dev Lastly, I wonder about how to get the main autoloaded namespace from the user, I know how to parse a composer.json, but i've seen that it's already done by the discovery process and I wonder if we can have this information without additional work or no

Not sure what you want to do but the \Tempest\Core\Composer class might help you

innocenzi avatar Oct 10 '24 11:10 innocenzi

@innocenzi Insane, thanks. I'll check this out soon !

Thanks I'll look into this class when the times come !

gturpin-dev avatar Oct 10 '24 12:10 gturpin-dev