diff --git a/composer.json b/composer.json
index b5887b4..53bf28d 100644
--- a/composer.json
+++ b/composer.json
@@ -11,6 +11,7 @@
},
"require": {
"php": ">=7.3",
+ "ext-json": "*",
"drupal/core": "^9.0",
"twig/twig": "^2.12"
},
diff --git a/drush.services.yml b/drush.services.yml
index 6cfc0aa..9521b80 100644
--- a/drush.services.yml
+++ b/drush.services.yml
@@ -4,3 +4,8 @@ services:
arguments: ['@twig']
tags:
- { name: console.command }
+ twig_tweak.debug:
+ class: Drupal\twig_tweak\Command\DebugCommand
+ arguments: ['@twig']
+ tags:
+ - { name: console.command }
diff --git a/src/Command/DebugCommand.php b/src/Command/DebugCommand.php
new file mode 100644
index 0000000..1206bfe
--- /dev/null
+++ b/src/Command/DebugCommand.php
@@ -0,0 +1,217 @@
+twig = $twig;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function configure(): void {
+ $this
+ ->setName('twig-tweak:debug')
+ ->setAliases(['twig-debug'])
+ ->addOption('filter', NULL, InputOption::VALUE_REQUIRED, 'Show details for all entries matching this filter.')
+ ->setDescription('Shows a list of twig functions, filters, globals and tests')
+ ->setHelp(<<<'EOF'
+ The %command.name% command outputs a list of twig functions,
+ filters, globals and tests.
+
+ drush %command.name%
+
+ The command lists all functions, filters, etc.
+
+ drush %command.name% --filter=date
+
+ The command lists everything that contains the word date.
+ EOF);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function execute(InputInterface $input, OutputInterface $output): int {
+ $io = new SymfonyStyle($input, $output);
+ $filter = $input->getOption('filter');
+
+ $types = ['functions', 'filters', 'tests'];
+ foreach ($types as $type) {
+ $items = [];
+ foreach ($this->twig->{'get' . ucfirst($type)}() as $name => $entity) {
+ if (!$filter || \strpos($name, $filter) !== FALSE) {
+
+ $signature = '';
+ // Tests are typically implemented as Twig nodes so that it is hard
+ // to get their signatures through reflection.
+ if ($type == 'filters' || $type == 'functions') {
+ try {
+ $meta = $this->getMetadata($type, $entity);
+ $default_signature = $type == 'functions' ? '()' : '';
+ $signature = $meta ? '(' . implode(', ', $meta) . ')' : $default_signature;
+ }
+ catch (\UnexpectedValueException $exception) {
+ $signature = sprintf(' %s', OutputFormatter::escape($exception->getMessage()));
+ }
+ }
+
+ $items[$name] = $name . $signature;
+ }
+ }
+
+ if (!$items) {
+ continue;
+ }
+
+ $io->section(\ucfirst($type));
+
+ ksort($items);
+ $io->listing($items);
+ }
+
+ if (!$filter && $loaderPaths = $this->getLoaderPaths()) {
+ $io->section('Loader Paths');
+ $rows = [];
+ foreach ($loaderPaths as $namespace => $paths) {
+ foreach ($paths as $path) {
+ $rows[] = [$namespace, $path . \DIRECTORY_SEPARATOR];
+ }
+ }
+ $io->table(['Namespace', 'Paths'], $rows);
+
+ }
+
+ return 0;
+ }
+
+ /**
+ * Gets loader paths.
+ */
+ private function getLoaderPaths(): array {
+ $loaderPaths = [];
+ foreach ($this->getFilesystemLoaders() as $loader) {
+ foreach ($loader->getNamespaces() as $namespace) {
+ $paths = $loader->getPaths($namespace);
+ $namespace = FilesystemLoader::MAIN_NAMESPACE === $namespace ?
+ '(None)' : '@' . $namespace;
+ $loaderPaths[$namespace] = array_merge($loaderPaths[$namespace] ?? [], $paths);
+ }
+ }
+ return $loaderPaths;
+ }
+
+ /**
+ * Gets metadata.
+ */
+ private function getMetadata(string $type, $entity): ?array {
+
+ $callable = $entity->getCallable();
+
+ if (!$callable) {
+ return NULL;
+ }
+ if (\is_array($callable)) {
+ if (!method_exists($callable[0], $callable[1])) {
+ return NULL;
+ }
+ $reflection = new \ReflectionMethod($callable[0], $callable[1]);
+ }
+ elseif (\is_object($callable) && method_exists($callable, '__invoke')) {
+ $reflection = new \ReflectionMethod($callable, '__invoke');
+ }
+ elseif (\function_exists($callable)) {
+ $reflection = new \ReflectionFunction($callable);
+ }
+ elseif (\is_string($callable) && preg_match('{^(.+)::(.+)$}', $callable, $m) && method_exists($m[1], $m[2])) {
+ $reflection = new \ReflectionMethod($m[1], $m[2]);
+ }
+ else {
+ throw new \UnexpectedValueException('Unsupported callback type.');
+ }
+
+ $args = $reflection->getParameters();
+
+ // Filter out context/environment args.
+ if ($entity->needsEnvironment()) {
+ array_shift($args);
+ }
+ if ($entity->needsContext()) {
+ array_shift($args);
+ }
+
+ if ($type == 'filters') {
+ // Remove the value the filter is applied on.
+ array_shift($args);
+ }
+
+ // Format args.
+ $args = array_map(
+ static function (\ReflectionParameter $param): string {
+ $arg = $param->getName();
+ if ($param->isDefaultValueAvailable()) {
+ $arg .= ' = ' . \json_encode($param->getDefaultValue());
+ }
+ return $arg;
+ },
+ $args,
+ );
+
+ return $args;
+ }
+
+ /**
+ * Returns files system loaders.
+ *
+ * @return \Twig\Loader\FilesystemLoader[]
+ * File system loaders.
+ */
+ private function getFilesystemLoaders(): array {
+ $loaders = [];
+
+ $loader = $this->twig->getLoader();
+ if ($loader instanceof FilesystemLoader) {
+ $loaders[] = $loader;
+ }
+ elseif ($loader instanceof ChainLoader) {
+ foreach ($loader->getLoaders() as $chained_loaders) {
+ if ($chained_loaders instanceof FilesystemLoader) {
+ $loaders[] = $chained_loaders;
+ }
+ }
+ }
+
+ return $loaders;
+ }
+
+}