diff --git a/classes/local/cli/application.php b/classes/local/cli/application.php index 53a7157..d3ecdd9 100644 --- a/classes/local/cli/application.php +++ b/classes/local/cli/application.php @@ -22,6 +22,7 @@ use local_devkit\local\cli\commands\env\env_show; use local_devkit\local\cli\commands\format; use local_devkit\local\cli\commands\lint\handler as lint_handler; +use local_devkit\local\cli\commands\make; use local_devkit\local\cli\commands\mcp\mcp_serve; use local_devkit\local\cli\commands\plugins\plugins_list; use local_devkit\local\cli\commands\purge\handler as purge_handler; @@ -46,6 +47,7 @@ public function __construct() { $this->addCommand(new database_table()); $this->addCommand(new env_show()); $this->addCommand(new format()); + $this->addCommand(new make()); $this->addCommand(new mcp_serve()); lint_handler::register($this); purge_handler::register($this); diff --git a/classes/local/cli/commands/make.php b/classes/local/cli/commands/make.php new file mode 100644 index 0000000..795bee8 --- /dev/null +++ b/classes/local/cli/commands/make.php @@ -0,0 +1,77 @@ +. + +namespace local_devkit\local\cli\commands; + +use local_devkit\local\format\base as format_base; +use local_devkit\local\format\phpcbf; +use local_devkit\local\format\pint; +use local_devkit\local\generators\snippets\base as snippet_base; +use local_devkit\local\generators\snippets\php_class; +use Symfony\Component\Console\Attribute\Argument; +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\Console\Style\SymfonyStyle; + +/** + * Command to generate boilerplates. + * + * @package local_devkit + * @copyright 2026 Felix + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +#[AsCommand( + name: 'make', + description: 'Make boilerplate.', +)] +class make extends Command { + /** + * Invoke. + */ + public function __invoke( + #[Argument('Path to new file.')] + string $path, + SymfonyStyle $io, + InputInterface $input, + OutputInterface $output, + ): int { + [$generatorclass, $formatterclassnames] = $this->get_generator_and_formatter_classnames(); + + // The generators need the file to actually exist. + @mkdir(dirname($path)); + file_put_contents($path, ''); + + $generator = new $generatorclass($path); + $contents = $generator->generate(); + file_put_contents($path, $contents); + + foreach ($formatterclassnames as $formatterclassname) { + \core\di::get($formatterclassname)::format($path); + } + + return Command::SUCCESS; + } + + /** + * Summary of get_generator_and_formatters + * @return array{class-string, class-string[]} + */ + private function get_generator_and_formatter_classnames() { + return [php_class::class, [pint::class, phpcbf::class]]; + } +} diff --git a/classes/local/generators/boilerplate.php b/classes/local/generators/boilerplate.php index 4712d04..37b6f6e 100644 --- a/classes/local/generators/boilerplate.php +++ b/classes/local/generators/boilerplate.php @@ -56,11 +56,11 @@ public static function get_boilerplate(bool $usehttps): string { } /** - * Get the canonical GPL boilerplate with JS line comments. + * Get the canonical GPL boilerplate with '//' comments. * @param bool $usehttps * @return string */ - public static function generate_for_javascript(bool $usehttps): string { + public static function generate_with_slash_comments(bool $usehttps): string { $raw = self::get_boilerplate($usehttps); $lines = explode("\n", rtrim($raw)); @@ -69,6 +69,24 @@ public static function generate_for_javascript(bool $usehttps): string { return implode("\n", $commented) . "\n"; } + /** + * Get the canonical GPL boilerplate with JS line comments. + * @param bool $usehttps + * @return string + */ + public static function generate_for_javascript(bool $usehttps): string { + return self::generate_with_slash_comments($usehttps); + } + + /** + * Get the canonical GPL boilerplate with JS line comments. + * @param bool $usehttps + * @return string + */ + public static function generate_for_php(bool $usehttps): string { + return self::generate_with_slash_comments($usehttps); + } + /** * Get the canonical GPL boilerplate wrapped as a Mustache comment block. * @param bool $usehttps diff --git a/classes/local/generators/snippets/base.php b/classes/local/generators/snippets/base.php new file mode 100644 index 0000000..179a769 --- /dev/null +++ b/classes/local/generators/snippets/base.php @@ -0,0 +1,154 @@ +. + +namespace local_devkit\local\generators\snippets; + +use local_devkit\local\component; +use local_devkit\local\generators\boilerplate; +use local_devkit\local\utils; +use Nette\PhpGenerator\ClassType; +use Nette\PhpGenerator\PhpFile; +use Nette\PhpGenerator\PhpNamespace; +use Nette\PhpGenerator\Printer; + +/** + * Class base + * + * @package local_devkit + * @copyright 2026 Felix + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +abstract class base { + /** @var string */ + protected readonly string $filepath; + /** @var string */ + protected readonly string $component; + /** @var string */ + protected readonly string $copyright; + /** @var string|null */ + protected ?string $category = null; + + /** + * Constructor. + */ + public function __construct(string $filepath) { + $this->filepath = utils::get_path_relative_to_moodle_root($filepath); + $component = component::resolve_component_from_path($this->filepath); + if ($component === null) { + throw new \InvalidArgumentException("Could not resolve component for path: $this->filepath"); + } + $this->component = $component; + + $year = date("Y"); + $this->copyright = "$year Your name"; + } + + /** + * Generates the snippet. + */ + abstract public function generate(): string; + + /** + * Standard PHP File. + */ + protected function php_file(): PhpFile { + return new PhpFile(); + } + + /** + * PHP Printer. + */ + protected function php_printer(): Printer { + $printer = new Printer(); + $printer->indentation = ' '; + $printer->bracesOnNextLine = false; + $printer->linesBetweenMethods = 1; + return $printer; + } + + /** + * Prints a php file, injects license info at top of file. + */ + protected function print_php_file(PhpFile $file): string { + $printer = $this->php_printer(); + + $phptag = 'printFile($file); + return str_replace($phptag, "$phptag\n$license", $content); + } + + /** + * Standard PHP File with class. + * @return array{PhpFile, PhpNamespace, ClassType} + */ + protected function php_file_with_namespaced_class(): array { + $file = $this->php_file(); + [$namespacename, $classname] = $this->php_class_info(); + $namespace = $file->addNamespace($namespacename); + $class = $namespace->addClass($classname); + $this->php_class_add_docblock_tags($class, $this->category); + return [$file, $namespace, $class]; + } + + /** + * Adds docblock to class. + */ + protected function php_class_add_docblock_tags( + ClassType $class, + ?string $category = null, + ): ClassType { + $license = 'https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later'; + + if (!$class->getComment()) { + $class->addComment("Class {$class->getName()}."); + } + + $class->addComment(''); + $class->addComment("@package $this->component"); + + if ($category) { + $class->addComment("@category $category"); + } + + $class->addComment("@copyright $this->copyright"); + $class->addComment("@license $license"); + return $class; + } + + /** + * Deduces the namespace and classname for a given path. + * @return array{string, string} + */ + protected function php_class_info(): array { + $dirpath = dirname($this->filepath); + $classname = basename($this->filepath, '.php'); + $componentpath = utils::get_path_relative_to_moodle_root( + \core_component::get_component_directory($this->component), + ); + + $classesdir = "$componentpath/classes"; + if (str_starts_with($dirpath, $classesdir)) { + $namespace = substr($dirpath, strlen($classesdir) + 1); + } else { + $namespace = ''; + } + $namespace = str_replace('/', '\\', $namespace); + $namespace = rtrim("$this->component\\$namespace", '\\'); + + return [$namespace, $classname]; + } +} diff --git a/classes/local/generators/snippets/php_class.php b/classes/local/generators/snippets/php_class.php new file mode 100644 index 0000000..20f7466 --- /dev/null +++ b/classes/local/generators/snippets/php_class.php @@ -0,0 +1,32 @@ +. + +namespace local_devkit\local\generators\snippets; + +/** + * Snippet for a standard PHP class file. + * + * @package local_devkit + * @copyright 2026 Felix + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class php_class extends base { + #[\Override] + public function generate(): string { + [$file] = $this->php_file_with_namespaced_class(); + return $this->print_php_file($file); + } +} diff --git a/classes/local/generators/snippets/task.php b/classes/local/generators/snippets/task.php new file mode 100644 index 0000000..623543b --- /dev/null +++ b/classes/local/generators/snippets/task.php @@ -0,0 +1,71 @@ +. + +namespace local_devkit\local\generators\snippets; + +use Nette\PhpGenerator\ClassManipulator; + +use function in_array; + +/** + * Class task + * + * @package local_devkit + * @copyright 2026 Felix + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class task extends php_class { + /** + * Constructor. + */ + public function __construct( + string $filepath, + /** @var 'scheduled'|'adhoc' $tasktype */ + private readonly string $tasktype = 'scheduled', + ) { + if (!in_array($tasktype, ['scheduled', 'adhoc'])) { + throw new \InvalidArgumentException('Invalid task type'); + } + parent::__construct($filepath); + } + + #[\Override] + public function generate(): string { + $this->category = 'task'; + [$file, $namespace, $class] = $this->php_file_with_namespaced_class(); + + $baseclass = match ($this->tasktype) { + 'scheduled' => \core\task\scheduled_task::class, + 'adhoc' => \core\task\adhoc_task::class, + }; + $namespace->addUse($baseclass); + + $class->setExtends($baseclass); + $manipulator = new ClassManipulator($class); + + $getname = $manipulator->inheritMethod('get_name'); + $getname->removeComment(); + $getname->addAttribute('Override'); + $getname->addBody('return get_string(?, ?);', ["task:{$class->getName()}", $this->component]); + + $execute = $manipulator->inheritMethod('execute'); + $execute->removeComment(); + $execute->addAttribute('Override'); + $class->setMethods([$getname, $execute]); + + return $this->print_php_file($file); + } +} diff --git a/composer.json b/composer.json index dc5da1c..49117f6 100644 --- a/composer.json +++ b/composer.json @@ -8,7 +8,8 @@ "mcp/sdk": "^0.6.0", "symfony/process": "^7.4", "symfony/yaml": "^7.4", - "symfony/finder": "^7.4" + "symfony/finder": "^7.4", + "nette/php-generator": "^4.2" }, "require-dev": { "phpstan/phpstan": "^2.1", diff --git a/composer.lock b/composer.lock index dd4e801..8733823 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "b2035bacecbe1dbf7448b4f6616ac055", + "content-hash": "1cf1c27472a8f35a45205154ef5b6fcb", "packages": [ { "name": "doctrine/deprecations", @@ -138,6 +138,171 @@ }, "time": "2026-06-02T15:47:04+00:00" }, + { + "name": "nette/php-generator", + "version": "v4.2.2", + "source": { + "type": "git", + "url": "https://github.com/nette/php-generator.git", + "reference": "0d7060926f5c3e8c488b9b9ced42d857f12a34b5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/php-generator/zipball/0d7060926f5c3e8c488b9b9ced42d857f12a34b5", + "reference": "0d7060926f5c3e8c488b9b9ced42d857f12a34b5", + "shasum": "" + }, + "require": { + "nette/utils": "^4.0.6", + "php": "8.1 - 8.5" + }, + "require-dev": { + "jetbrains/phpstorm-attributes": "^1.2", + "nette/phpstan-rules": "^1.0", + "nette/tester": "^2.6", + "nikic/php-parser": "^5.0", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1.40@stable", + "tracy/tracy": "^2.8" + }, + "suggest": { + "nikic/php-parser": "to use ClassType::from(withBodies: true) & ClassType::fromCode()" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.2-dev" + } + }, + "autoload": { + "psr-4": { + "Nette\\": "src" + }, + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-3.0-only" + ], + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + }, + { + "name": "Nette Community", + "homepage": "https://nette.org/contributors" + } + ], + "description": "🐘 Nette PHP Generator: generates neat PHP code for you. Supports new PHP 8.5 features.", + "homepage": "https://nette.org", + "keywords": [ + "code", + "nette", + "php", + "scaffolding" + ], + "support": { + "issues": "https://github.com/nette/php-generator/issues", + "source": "https://github.com/nette/php-generator/tree/v4.2.2" + }, + "time": "2026-02-26T00:58:33+00:00" + }, + { + "name": "nette/utils", + "version": "v4.1.5", + "source": { + "type": "git", + "url": "https://github.com/nette/utils.git", + "reference": "b043439dbdf954e6c28b5ea7e34b0100f83165e0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/utils/zipball/b043439dbdf954e6c28b5ea7e34b0100f83165e0", + "reference": "b043439dbdf954e6c28b5ea7e34b0100f83165e0", + "shasum": "" + }, + "require": { + "php": "8.2 - 8.5" + }, + "conflict": { + "nette/finder": "<3", + "nette/schema": "<1.2.2" + }, + "require-dev": { + "jetbrains/phpstorm-attributes": "^1.2", + "nette/phpstan-rules": "^1.0", + "nette/tester": "^2.5", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1@stable", + "tracy/tracy": "^2.9" + }, + "suggest": { + "ext-gd": "to use Image", + "ext-iconv": "to use Strings::chr(), ord() and reverse()", + "ext-intl": "to use Strings::webalize(), toAscii(), normalize() and compare()", + "ext-json": "to use Nette\\Utils\\Json", + "ext-mbstring": "to use Strings::lower() etc...", + "ext-tokenizer": "to use Nette\\Utils\\Reflection::getUseStatements()" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.1-dev" + } + }, + "autoload": { + "psr-4": { + "Nette\\": "src" + }, + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-3.0-only" + ], + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + }, + { + "name": "Nette Community", + "homepage": "https://nette.org/contributors" + } + ], + "description": "🛠 Nette Utils: lightweight utilities for string & array manipulation, image handling, safe JSON encoding/decoding, validation, slug or strong password generating etc.", + "homepage": "https://nette.org", + "keywords": [ + "array", + "core", + "datetime", + "images", + "json", + "nette", + "paginator", + "password", + "slugify", + "string", + "unicode", + "utf-8", + "utility", + "validation" + ], + "support": { + "issues": "https://github.com/nette/utils/issues", + "source": "https://github.com/nette/utils/tree/v4.1.5" + }, + "time": "2026-07-17T23:02:45+00:00" + }, { "name": "opis/json-schema", "version": "2.6.0",