diff --git a/app/controllers/authenticated_controller.php b/app/controllers/authenticated_controller.php index f50d478c9fad931612d0c32b74dc1acc614446c9..3f4da39307fc6d5a90d9b5876ee2308aff1f26ef 100644 --- a/app/controllers/authenticated_controller.php +++ b/app/controllers/authenticated_controller.php @@ -12,7 +12,7 @@ * the License, or (at your option) any later version. */ -class AuthenticatedController extends StudipController +abstract class AuthenticatedController extends StudipController { protected $with_session = true; //we do need to have a session for this controller protected $allow_nobody = false; //nobody is not allowed and always gets a login-screen diff --git a/cli/Commands/SORM/DescribeModels.php b/cli/Commands/SORM/DescribeModels.php index 668de5bdd25f7d3a9b171c8339234a092cf30a86..1387a1640b342c1749de5259d8241e97d74a1713 100644 --- a/cli/Commands/SORM/DescribeModels.php +++ b/cli/Commands/SORM/DescribeModels.php @@ -29,7 +29,7 @@ final class DescribeModels extends AbstractCommand protected function configure(): void { $this->setDescription('Describe models'); - $this->setHelp('This command will add neccessary @property annotations to SimpleORMap classes in a folder'); + $this->setHelp('This command will add neccessary @property and @method annotations to SimpleORMap classes in a folder'); $this->addArgument( 'folder', diff --git a/cli/Commands/Trails/DescribeControllers.php b/cli/Commands/Trails/DescribeControllers.php new file mode 100644 index 0000000000000000000000000000000000000000..396498a023c4ca7b4c90982ea9303dcd0e181257 --- /dev/null +++ b/cli/Commands/Trails/DescribeControllers.php @@ -0,0 +1,270 @@ +<?php +namespace Studip\Cli\Commands\Trails; + +use SimpleORMapCollection; +use Studip\Cli\Commands\AbstractCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Helper\ProgressBar; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +final class DescribeControllers extends AbstractCommand +{ + protected static $defaultName = 'trails:describe'; + + private $progress; + + protected function configure(): void + { + $this->setDescription('Describe controllers'); + $this->setHelp('This command will add neccessary @property annotations to SimpleORMap classes in a folder'); + + $this->addArgument( + 'folder', + InputArgument::OPTIONAL, + 'Folder to scan (will default to app/controllers)', + 'app/controllers' + ); + + $this->addOption( + 'recursive', + 'r', + InputOption::VALUE_NEGATABLE, + 'Scan into subfolders recursively' + ); + $this->addOption( + 'bootstrap', + 'b', + InputOption::VALUE_OPTIONAL, + 'Execute bootstrap file before scanning folder' + ); + } + + public function execute(InputInterface $input, OutputInterface $output) + { + $bootstrap = $input->getOption('bootstrap'); + if ($bootstrap) { + if (!file_exists($bootstrap)) { + throw new \Exception("Invalid bootstrap file {$bootstrap} provided"); + } + require_once $bootstrap; + } + + $recursive = $input->getOption('recursive') ?? false; + $folder = $input->getArgument('folder'); + $iterator = $this->getFolderIterator($folder, $recursive, ['php']); + + $this->progress = new ProgressBar($output, iterator_count($iterator)); + $this->progress->start(); + + foreach ($iterator as $file) { + $filename = $file->getFilename(); + + if (!is_writable($file->getRealPath())) { + $this->outputForFile( + $output, + '<comment>Skipping not writable file ' . $this->relativeFilePath($file->getPathname()) . '</comment>' + ); + continue; + } + + $class_name = $this->getClassNameFromFile($file->getPathname()); + if ($class_name === null) { + $this->outputForFile( + $output, + "Could not determine class name for file {$filename} ({$file->getPathName()})", + OutputInterface::VERBOSITY_VERBOSE + ); + continue; + } + + require_once $file->getPathName(); + + if (!class_exists($class_name) || !is_subclass_of($class_name, \Trails_Controller::class)) { + $this->outputForFile( + $output, + "Skipping invalid class file {$filename} (class {$class_name} / file {$file->getPathName()})", + OutputInterface::VERBOSITY_VERBOSE + ); + continue; + } + + try { + $reflection = new \ReflectionClass($class_name); + } catch (\Error $e) { + $this->outputForFile( + $output, + "<error>Could not get reflection for class {$class_name} ({$e->getMessage()})</error>" + ); + continue; + } + + if ($reflection->isAbstract()) { + $this->outputForFile( + $output, + "Skipping abstract class {$class_name}", + OutputInterface::VERBOSITY_VERBOSE + ); + continue; + } + + if (false && $this->updateDocBlockOfClass($reflection, $properties, $methods)) { + $this->outputForFile( + $output, + '<info>Updated ' . $this->relativeFilePath($file->getPathname()) . '</info>' + ); + } else { + $this->outputForFile( + $output, + 'No changes in ' . $this->relativeFilePath($file->getPathname()), + OutputInterface::VERBOSITY_VERBOSE + ); + } + } + + $this->progress->clear(); + + return Command::SUCCESS; + } + + private function outputForFile($output, ...$args) + { + $this->progress->advance(); + $this->progress->clear(); + $output->writeln(...$args); + $this->progress->display(); + + } + + private function getPHPType($info) + { + if (preg_match('/^(?:tiny|small|medium|big)?int(?:eger)?/iS', $info['type'])) { + return 'int'; + } + + if (preg_match('/^(?:decimal|double|float|numeric)/iS', $info['type'])) { + return 'float'; + } + + if (preg_match('/^bool(?:ean)?/iS', $info['type'])) { + return 'bool'; + } + + return 'string'; + } + + private function updateDocBlockOfClass(\ReflectionClass $reflection, array $properties, array $methods): bool + { + $has_docblock = (bool) $reflection->getDocComment(); + $docblock = $reflection->getDocComment() ?: $this->getDefaultDocblock(); + + $docblock_lines = array_map('rtrim', explode("\n", $docblock)); + + $properties_started = false; + $docblock_lines = array_filter($docblock_lines, function ($line) use (&$properties_started) { + $line = ltrim($line, '* '); + if ($properties_started) { + return $line === '/'; + } + + $properties_started = strpos($line, '@property ') === 0; + return !$properties_started; + }); + + $docblock_lines = array_reverse($docblock_lines); + while ($docblock_lines[1] === ' *') { + array_splice($docblock_lines, 1, 1); + } + $docblock_lines = array_reverse($docblock_lines); + + $properties = array_map(function ($variable, $property) { + return " * @property {$property['type']} \${$variable} {$property['description']}"; + }, array_keys($properties), array_values($properties)); + + array_unshift($properties, ' *'); + array_splice($docblock_lines, -1, 0, $properties); + + $methods = array_map(function ($method) { + return " * @method {$method}"; + }, $methods); + + array_unshift($methods, ' *'); + array_splice($docblock_lines, -1, 0, $methods); + + $new_docblock = implode("\n", $docblock_lines); + + if ($docblock === $new_docblock) { + return false; + } + + $contents = file_get_contents($reflection->getFileName()); + if ($has_docblock) { + $contents = str_replace($docblock, $new_docblock, $contents); + } else { + $contents = preg_replace( + '/^class/m', + $new_docblock . "\nclass", + $contents, + 1 + ); + } + + file_put_contents($reflection->getFileName(), $contents); + + return true; + } + + private function getDefaultDocBlock(): string + { + return implode("\n", [ + '/**', + ' * @license GPL2 or any later version', + ' */', + ]); + } + + /** + * @see https://stackoverflow.com/a/14250011 + */ + private function getClassNameFromFile(string $filename, string $expected_class = null): ?string + { + $code = file_get_contents($filename); + $tokens = token_get_all($code); + + $namespace = ''; + + for ($i = 0, $l = count($tokens); $i < $l; $i += 1) { + if ($tokens[$i][0] === T_NAMESPACE) { + for ($j= $i + 1; $j < $l; $j += 1) { + if ($tokens[$j][0] === T_STRING) { + $namespace .= "\\" . $tokens[$j][1]; + } elseif ($tokens[$j] === '{' || $tokens[$j] === ';') { + break; + } + } + } + if ($tokens[$i][0] === T_CLASS) { + for ($j = $i + 1; $j < $l; $j += 1) { + if ($tokens[$j] === '{') { + return ltrim($namespace . "\\" . $tokens[$i + 2][1], "\\"); + } + } + } + } + return null; + } + + private function makeMethods(\ReflectionClass $reflection, string $field): array + { + $result = []; + foreach (self::METHODS_TEMPLATES as $name => $template) { + $method = sprintf($name, $field); + if (!$reflection->hasMethod($method)) { + $result[] = sprintf($template, $field); + } + } + return $result; + } +} diff --git a/cli/studip b/cli/studip index af6273f62b89690f2186fafb16dfb671498f5dab..5bfd0f643fdee33468e4120acb462085dc8850d0 100755 --- a/cli/studip +++ b/cli/studip @@ -52,6 +52,7 @@ $commands = [ Commands\Plugins\I18N\I18NCompile::class, Commands\Resources\UpdateBookingIntervals::class, Commands\SORM\DescribeModels::class, + Commands\Trails\DescribeControllers::class, Commands\Users\UserDelete::class, Commands\Users\UserDelete::class, ];