What happened before
I got the idea to create make commands for a more versatile directory structure. The idea was based on the different DDD file structures.
In my previous post I tried to find a way to deal with variable file structures to create a file.
The day after the post I realised the path I was walking leads me to a framework agnostic package.
Starting Codestarter
In this second fase I concentrated on code editor like experience in the command line.
But first I had to move from artisan to Symfony console. Artisan is build on top of console so it is not that big of a step.
Because I want people to use it with multiple frameworks I create a codestarter file in the bin directory.
if (!is_dir(dirname(__DIR__).'/vendor')) {
throw new LogicException('Dependencies are missing. Try running "composer install".');
}
require dirname(__DIR__).'/vendor/autoload.php';
use Symfony\Component\Console\Application;
use Symfony\Component\Filesystem\Filesystem;
use Xwero\Codestarter\Console\ClassGenerator;
use Xwero\Codestarter\Console\CodeStarter;
$application = new Application('codestarter', '0.1.0');
$command = new CodeStarter(new Filesystem());
$application->add($command);
$application->add(new ClassGenerator(new Filesystem()));
$application->setDefaultCommand($command->getName());
$application->run();
Because I know the main command will be quite some code, I made the decision to start it in a separate class.
I also added the Symfony filesystem package to the project. That makes it easier to do directory and file manipulations.
How to gather file content
Looking at artisan and the Symfony maker bundle, I saw two different ways to build content.
Artisan works with stub files which are text files with a Twig syntax for the variables.
And the maker bundle works with php templates.
I choose for the php templates because I wanted more context in the template file. While I'm not a fan of adding context to templates for a website, here it makes sense for easier extensibility of the command.
// templates/type.php
<?= "<?php\n" ?>
namespace <?= $content->namespace; ?>;
<?= $content->getImports() ?>
<?= $content->getTypeDefinition() ?>
<?= "\n".$content->getMethods()."\n\n" ?>
}
The next step is to add content to the templates.
A DTO like object is a natural fit.
class Content
{
public function __construct(
public string $namespace = '',
public array $imports = [],
public Type $type = Type::Content,
public array $typePrefixes = [],
public string $typeName = '',
public array $typeExtends = [],
public array $typeImplements = [],
public array $methods = [],
public string $content = '',
)
{}
public static function getFileContents(string $path, Content|Method $content): string
{
if (is_file($path)) {
ob_start();
include $path;
return ob_get_clean();
}
return '';
}
public function getImports(): string
{
$useLines = '';
if(count($this->imports) > 0) {
foreach($this->imports as $import) {
$useLines .= 'use '.$import.";\n";
}
}
return $useLines;
}
public function getTypeDefinition(): string
{
$definition = '';
if(count($this->typePrefixes) > 0) {
$definition .= join(' ', $this->typePrefixes);
}
$definition .= ' '.$this->getType().' '.$this->typeName;
if(count($this->typeExtends) > 0) {
$definition .= ' extends '.join(', ', $this->typeExtends);
}
if(count($this->typeImplements) > 0) {
$definition .= ' implements '.join(', ', $this->typeImplements);
}
$definition .= "\n{";
return $definition;
}
public function getMethods(): string
{
$methods = '';
if(count($this->methods) > 0) {
foreach($this->methods as $method) {
$methods .= self::getFileContents(TypeTemplate::Method->value, $method);
}
}
return $methods;
}
private function getType() : string
{
return match($this->type) {
Type::Content => '',
Type::Object => 'class',
Type::Enum => 'enum',
Type::Interface => 'interface',
Type::Trait => 'trait',
};
}
}
I provided all the properties with default values, because it is my intention that de command also can create Blade, Twig, and other files.
The getFileContents
method was a blast from the past for me. It has been ages I needed to get a file with php content.
I love the simplicity of it. The file is included and it knows the content
variable.
The getMethods
method shows that I created a Method
DTO because a method is a bit too much to use without a template or a DTO.
class Method
{
public function __construct(
public string $name,
public array $prefixes = [],
public array $arguments = [],
public string $outputType = '',
public string $content = '',
)
{}
public function getDefinition(): string
{
$definition = '';
if(count($this->prefixes) > 0) {
$definition .= join(' ', $this->prefixes);
}
$definition .= ' function '.$this->name.'('.$this->getArguments().")";
if(strlen($this->outputType) > 0) {
$definition .= ' : '.$this->outputType;
}
return $definition."\n";
}
public function getContent(): string
{
$content = '';
$indent = ' ';
if(strlen($this->content) > 0) {
$lines = explode(PHP_EOL, $this->content);
$indentedLines = array_map(function($line) use ($indent) {
return $indent.$indent.$line;
}, $lines);
$content = "\n".implode(PHP_EOL, $indentedLines)."\n".$indent;
}
return $content;
}
private function getArguments()
{
return join(', ', $this->arguments);
}
}
I struggled a bit with the method body indentation, because I wanted to let developers add it using multi-line input. So in the getContent
method you see I added spaces to each line.
For the arguments It will be possible to add visibility and a type. But I decided not to work it out, that is for the next fase.
The Codestarter command
Now that I have my base to add content to files, I can focus on getting the content from the command line input.
The first big task I had to tackle was to find a way to add the known classes of the project in the most convenient way possible.
Symfony console provides autocomplete as the part of the question helper.
I discovered that the get_declared_classes PHP function and the composer class map don't contain all the classes from the vendor directory and the source directory.
Because I didn't want to spend too much time I looked for a package that could could get classes from one or more base directories. And I found wyrihaximus/list-classes-in-directory.
It is fast because it uses a generator, but for the autocomplete I need an array. And because of the many classes in the vendor directory this took too long.
My solution was to create a new command that caches the classes in a text file.
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Filesystem\Filesystem;
use WyriHaximus\Lister;
#[AsCommand(name: "generate-classes")]
class ClassGenerator extends Command
{
public function __construct(private Filesystem $fs)
{
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$classes = Lister::classesInDirectories(
dirname(__DIR__) . '/../src',
dirname(__DIR__) . '/../vendor'
);
$t= '';
foreach ($classes as $class) {
$t .= $class."\n";
}
$this->fs->dumpFile('classes.txt', $t);
return Command::SUCCESS;
}
}
And that lead to a getAutoLoadedClassQuestion
method in the Codestarter command, that uses the text file.
private function getAutoLoadedClassQuestion(string $question): Question
{
$question = new Question($question."\n");
$content = $this->fs->readFile('classes.txt');
$classes = explode("\n", $content);
$question->setAutocompleterValues($classes);
return $question;
}
This function is the base for filling the $typeExtends
and $typeImplement
properties of the Content
class.
$helper = $this->getHelper('question');
$question = new ConfirmationQuestion('Do you want to interactively add extended classes ', false);
$extends = [];
$imports = [];
if($helper->ask($input, $output, $question)){
while (true) {
$answer = $helper->ask($input, $output, $this->getAutoLoadedClassQuestion('Start typing to autocomplete the class'));
if (empty($answer)) {
break;
}
$this->addClass($answer, $extends, $imports);
}
}
// later in the command
$content = new Content();
$content->typeExtends = $extends;
$content->imports = $imports;
I wrote an addClass
method with the method arguments in the back of my mind.
private function addClass(string $classpath, array &$classNames, array &$imports) : void
{
if(str_contains($classpath, '\\')){
$classNames[] = substr($classpath, strrpos($classpath, '\\') + 1);
$imports[] = $classpath;
} else {
$classNames[] = $classpath;
}
}
Next steps
Now I have to combine all the things I learned this week to create a cleaned up version of the code I already have. And create a package.
A future step is to make it possible to add custom questions procedure to the command with a custom DTO and template.
For the people who wonder how I landed on the Codestarter name. I didn't want to call it make something because the code creation is not that specific in my opinion. The command is meant as a way to create a more functional code file than is possible with an editor. Even editor templates can't add methods. The command is a good start, hence Codestarter.
Top comments (0)