diff --git a/php-templates/LICENSE.md b/php-templates/LICENSE.md new file mode 100644 index 000000000..79810c848 --- /dev/null +++ b/php-templates/LICENSE.md @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) Taylor Otwell + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/php-templates/README.md b/php-templates/README.md new file mode 100644 index 000000000..40f0e54d3 --- /dev/null +++ b/php-templates/README.md @@ -0,0 +1,5 @@ +The templates here are based on the official VS Code extension by Laravel https://github.com/laravel/vs-code-extension + +Modifications: + - return instead of echo + - do not serialize to JSON diff --git a/php-templates/configs.php b/php-templates/configs.php new file mode 100644 index 000000000..9f6c4be3e --- /dev/null +++ b/php-templates/configs.php @@ -0,0 +1,131 @@ +merge(glob(config_path('**/*.php'))) + ->map(fn ($path) => [ + (string) Illuminate\Support\Str::of($path) + ->replace([config_path('/'), '.php'], '') + ->replace('/', '.'), + $path, + ]); + +$vendor = collect(glob(base_path('vendor/**/**/config/*.php')))->map(fn ( + $path +) => [ + (string) Illuminate\Support\Str::of($path) + ->afterLast('/config/') + ->replace('.php', '') + ->replace('/', '.'), + $path, +]); + +$configPaths = $local + ->merge($vendor) + ->groupBy(0) + ->map(fn ($items) => $items->pluck(1)); + +$cachedContents = []; +$cachedParsed = []; + +function vsCodeGetConfigValue($value, $key, $configPaths) +{ + $parts = explode('.', $key); + $toFind = $key; + $found = null; + + while (count($parts) > 0) { + array_pop($parts); + $toFind = implode('.', $parts); + + if ($configPaths->has($toFind)) { + $found = $toFind; + break; + } + } + + if ($found === null) { + return null; + } + + $file = null; + $line = null; + + if ($found === $key) { + $file = $configPaths->get($found)[0]; + } else { + foreach ($configPaths->get($found) as $path) { + $cachedContents[$path] ??= file_get_contents($path); + $cachedParsed[$path] ??= token_get_all($cachedContents[$path]); + + $keysToFind = Illuminate\Support\Str::of($key) + ->replaceFirst($found, '') + ->ltrim('.') + ->explode('.'); + + if (is_numeric($keysToFind->last())) { + $index = $keysToFind->pop(); + + if ($index !== '0') { + return null; + } + + $key = collect(explode('.', $key)); + $key->pop(); + $key = $key->implode('.'); + $value = 'array(...)'; + } + + $nextKey = $keysToFind->shift(); + $expectedDepth = 1; + + $depth = 0; + + foreach ($cachedParsed[$path] as $token) { + if ($token === '[') { + $depth++; + } + + if ($token === ']') { + $depth--; + } + + if (!is_array($token)) { + continue; + } + + $str = trim($token[1], '"\''); + + if ( + $str === $nextKey && + $depth === $expectedDepth && + $token[0] === T_CONSTANT_ENCAPSED_STRING + ) { + $nextKey = $keysToFind->shift(); + $expectedDepth++; + + if ($nextKey === null) { + $file = $path; + $line = $token[2]; + break; + } + } + } + + if ($file) { + break; + } + } + } + + return [ + 'name' => $key, + 'value' => $value, + 'file' => $file === null ? null : str_replace(base_path('/'), '', $file), + 'line' => $line, + ]; +} + +return collect(Illuminate\Support\Arr::dot(config()->all())) + ->map(fn ($value, $key) => vsCodeGetConfigValue($value, $key, $configPaths)) + ->filter() + ->values(); diff --git a/php-templates/routes.php b/php-templates/routes.php new file mode 100644 index 000000000..ea5ace267 --- /dev/null +++ b/php-templates/routes.php @@ -0,0 +1,46 @@ +getActionName() === 'Closure') { + return new ReflectionFunction($route->getAction()['uses']); + } + + if (!str_contains($route->getActionName(), '@')) { + return new ReflectionClass($route->getActionName()); + } + + try { + return new ReflectionMethod($route->getControllerClass(), $route->getActionMethod()); + } catch (Throwable $e) { + $namespace = app(Illuminate\Routing\UrlGenerator::class)->getRootControllerNamespace() + ?? (app()->getNamespace() . 'Http\Controllers'); + + return new ReflectionMethod( + $namespace . '\\' . ltrim($route->getControllerClass(), '\\'), + $route->getActionMethod(), + ); + } +} + +return collect(app('router')->getRoutes()->getRoutes()) + ->map(function (Illuminate\Routing\Route $route) { + try { + $reflection = vsCodeGetRouterReflection($route); + } catch (Throwable $e) { + $reflection = null; + } + + return [ + 'method' => collect($route->methods())->filter(function ($method) { + return $method !== 'HEAD'; + })->implode('|'), + 'uri' => $route->uri(), + 'name' => $route->getName(), + 'action' => $route->getActionName(), + 'parameters' => $route->parameterNames(), + 'filename' => $reflection ? $reflection->getFileName() : null, + 'line' => $reflection ? $reflection->getStartLine() : null, + ]; + }) +; diff --git a/php-templates/translations.php b/php-templates/translations.php new file mode 100644 index 000000000..16f176c1a --- /dev/null +++ b/php-templates/translations.php @@ -0,0 +1,143 @@ +filter() + ->first(); + + $fileLines = Illuminate\Support\Facades\File::lines($file); + $lines = []; + $inComment = false; + + foreach ($fileLines as $index => $line) { + $trimmed = trim($line); + + if (substr($trimmed, 0, 2) === '/*') { + $inComment = true; + continue; + } + + if ($inComment) { + if (substr($trimmed, -2) !== '*/') { + continue; + } + + $inComment = false; + } + + if (substr($trimmed, 0, 2) === '//') { + continue; + } + + $lines[] = [$index + 1, $trimmed]; + } + + return [ + 'k' => $key, + 'la' => $lang, + 'vs' => collect(Illuminate\Support\Arr::dot((Illuminate\Support\Arr::wrap(__($key, [], $lang))))) + ->map( + fn ($value, $key) => vsCodeTranslationValue( + $key, + $value, + str_replace(base_path(DIRECTORY_SEPARATOR), '', $file), + $lines + ) + ) + ->filter(), + ]; +} + +function vsCodeTranslationValue($key, $value, $file, $lines): ?array +{ + if (is_array($value)) { + return null; + } + + $lineNumber = 1; + $keys = explode('.', $key); + $index = 0; + $currentKey = array_shift($keys); + + foreach ($lines as $index => $line) { + if ( + strpos($line[1], '"' . $currentKey . '"', 0) !== false || + strpos($line[1], "'" . $currentKey . "'", 0) !== false + ) { + $lineNumber = $line[0]; + $currentKey = array_shift($keys); + } + + if ($currentKey === null) { + break; + } + } + + return [ + 'v' => $value, + 'p' => $file, + 'li' => $lineNumber, + 'pa' => preg_match_all("/\:([A-Za-z0-9_]+)/", $value, $matches) + ? $matches[1] + : [], + ]; +} + +function vscodeCollectTranslations(string $path, ?string $namespace = null) +{ + $realPath = realpath($path); + + if (!is_dir($realPath)) { + return collect(); + } + + return collect(Illuminate\Support\Facades\File::allFiles($realPath))->map( + fn ($file) => vsCodeGetTranslationsFromFile($file, $path, $namespace) + ); +} + +$loader = app('translator')->getLoader(); +$namespaces = $loader->namespaces(); + +$reflection = new ReflectionClass($loader); +$property = $reflection->hasProperty('paths') + ? $reflection->getProperty('paths') + : $reflection->getProperty('path'); +$property->setAccessible(true); + +$paths = Illuminate\Support\Arr::wrap($property->getValue($loader)); + +$default = collect($paths)->flatMap( + fn ($path) => vscodeCollectTranslations($path) +); + +$namespaced = collect($namespaces)->flatMap( + fn ($path, $namespace) => vscodeCollectTranslations($path, $namespace) +); + +$final = []; + +foreach ($default->merge($namespaced) as $value) { + foreach ($value['vs'] as $key => $v) { + $dotKey = "{$value['k']}.{$key}"; + + if (!isset($final[$dotKey])) { + $final[$dotKey] = []; + } + + $final[$dotKey][$value['la']] = $v; + + if ($value['la'] === Illuminate\Support\Facades\App::currentLocale()) { + $final[$dotKey]['default'] = $v; + } + } +} + +return collect($final); diff --git a/php-templates/views.php b/php-templates/views.php new file mode 100644 index 000000000..5df54b1d3 --- /dev/null +++ b/php-templates/views.php @@ -0,0 +1,61 @@ +files() + ->name('*.blade.php') + ->in($path) as $file + ) { + $paths[] = [ + 'path' => str_replace(base_path(DIRECTORY_SEPARATOR), '', $file->getRealPath()), + 'isVendor' => str_contains($file->getRealPath(), base_path('vendor')), + 'key' => Illuminate\Support\Str::of($file->getRealPath()) + ->replace(realpath($path), '') + ->replace('.blade.php', '') + ->ltrim(DIRECTORY_SEPARATOR) + ->replace(DIRECTORY_SEPARATOR, '.'), + ]; + } + + return $paths; +} +$paths = collect( + app('view') + ->getFinder() + ->getPaths() +)->flatMap(function ($path) { + return vsCodeFindBladeFiles($path); +}); + +$hints = collect( + app('view') + ->getFinder() + ->getHints() +)->flatMap(function ($paths, $key) { + return collect($paths)->flatMap(function ($path) use ($key) { + return collect(vsCodeFindBladeFiles($path))->map(function ($value) use ( + $key + ) { + return array_merge($value, ['key' => "{$key}::{$value['key']}"]); + }); + }); +}); + +[$local, $vendor] = $paths + ->merge($hints) + ->values() + ->partition(function ($v) { + return !$v['isVendor']; + }); + +return $local + ->sortBy('key', SORT_NATURAL) + ->merge($vendor->sortBy('key', SORT_NATURAL)); diff --git a/resources/views/meta.php b/resources/views/meta.php index 5b7b9c241..38fb1bb8b 100644 --- a/resources/views/meta.php +++ b/resources/views/meta.php @@ -21,6 +21,14 @@ ])); + + override(, map([ + $value) : ?> + '' => '', + + ])); + + override(\factory(0), map([ '' => '@FactoryBuilder', @@ -65,4 +73,20 @@ override(\tap(0), type(0)); override(\optional(0), type(0)); + + $argumentsList) : ?> + registerArgumentsSet('', $arg) : ?>,); + + + + + $arguments) : ?> + $argumentSet) : ?> +expectedArguments(, , argumentsSet('')); + + + + } diff --git a/src/Console/MetaCommand.php b/src/Console/MetaCommand.php index 72f20f150..7e83cdacb 100644 --- a/src/Console/MetaCommand.php +++ b/src/Console/MetaCommand.php @@ -16,6 +16,7 @@ use Illuminate\Contracts\Config\Repository; use Illuminate\Contracts\View\Factory; use Illuminate\Filesystem\Filesystem; +use Illuminate\Support\Collection; use RuntimeException; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; @@ -51,19 +52,29 @@ class MetaCommand extends Command protected $config; protected $methods = [ - 'new \Illuminate\Contracts\Container\Container', - '\Illuminate\Container\Container::makeWith(0)', - '\Illuminate\Contracts\Container\Container::get(0)', - '\Illuminate\Contracts\Container\Container::make(0)', - '\Illuminate\Contracts\Container\Container::makeWith(0)', - '\App::get(0)', - '\App::make(0)', - '\App::makeWith(0)', - '\app(0)', - '\resolve(0)', - '\Psr\Container\ContainerInterface::get(0)', + 'new \Illuminate\Contracts\Container\Container', + '\Illuminate\Container\Container::makeWith(0)', + '\Illuminate\Contracts\Container\Container::get(0)', + '\Illuminate\Contracts\Container\Container::make(0)', + '\Illuminate\Contracts\Container\Container::makeWith(0)', + '\App::get(0)', + '\App::make(0)', + '\App::makeWith(0)', + '\app(0)', + '\resolve(0)', + '\Psr\Container\ContainerInterface::get(0)', ]; + protected $configMethods = [ + '\config()', + '\Illuminate\Config\Repository::get()', + '\Illuminate\Config\Repository::set()', + '\Illuminate\Support\Facades\Config::get()', + '\Illuminate\Support\Facades\Config::set()', + ]; + + protected $templateCache = []; + /** * * @param Filesystem $files @@ -108,7 +119,7 @@ public function handle() } $reflectionClass = new \ReflectionClass($concrete); - if (is_object($concrete) && !$reflectionClass->isAnonymous()) { + if (is_object($concrete) && !$reflectionClass->isAnonymous() && $abstract !== get_class($concrete)) { $bindings[$abstract] = get_class($concrete); } } catch (\Throwable $e) { @@ -120,10 +131,18 @@ public function handle() $this->unregisterClassAutoloadExceptions($ourAutoloader); - $content = $this->view->make('meta', [ - 'bindings' => $bindings, - 'methods' => $this->methods, - 'factories' => $factories, + $configValues = $this->loadTemplate('configs')->pluck('value', 'name')->map(function ($value, $key) { + return gettype($value); + }); + + $content = $this->view->make('ide-helper::meta', [ + 'bindings' => $bindings, + 'methods' => $this->methods, + 'factories' => $factories, + 'configMethods' => $this->configMethods, + 'configValues' => $configValues, + 'expectedArgumentSets' => $this->getExpectedArgumentSets(), + 'expectedArguments' => $this->getExpectedArguments(), ])->render(); $filename = $this->option('filename'); @@ -167,6 +186,83 @@ protected function registerClassAutoloadExceptions(): callable return $autoloader; } + protected function getExpectedArgumentSets() + { + return [ + 'configs' => $this->loadTemplate('configs')->pluck('name')->filter()->toArray(), + 'routes' => $this->loadTemplate('routes')->pluck('name')->filter()->toArray(), + 'views' => $this->loadTemplate('views')->pluck('key')->filter()->map(function ($value) { + return (string) $value; + })->toArray(), + 'translations' => $this->loadTemplate('translations')->filter()->keys()->toArray(), + ]; + } + + protected function getExpectedArguments() + { + return [ + '\config()' => [ + 0 => 'configs', + ], + '\Illuminate\Config\Repository::get()' => [ + 0 => 'configs', + ], + '\Illuminate\Config\Repository::set()' => [ + 0 => 'configs', + ], + '\Illuminate\Support\Facades\Config::get()' => [ + 0 => 'configs', + ], + '\Illuminate\Support\Facades\Config::set()' => [ + 0 => 'configs', + ], + '\route()' => [ + 0 => 'routes', + ], + '\Illuminate\Support\Facades\Route::get()' => [ + 0 => 'routes', + ], + '\Illuminate\Routing\Router::get()' => [ + 0 => 'routes', + ], + '\view()' => [ + 0 => 'views', + ], + '\Illuminate\Support\Facades\View::make()' => [ + 0 => 'views', + ], + '\Illuminate\View\Factory::make()' => [ + 0 => 'views', + ], + '\__()' => [ + 0 => 'translations', + ], + '\trans()' => [ + 0 => 'translations', + ], + '\Illuminate\Contracts\Translation\Translator::get()' => [ + 0 => 'translations', + ], + ]; + } + + /** + * @return Collection + */ + protected function loadTemplate($name) + { + if (!isset($this->templateCache[$name])) { + $file = __DIR__ . '/../../php-templates/' . basename($name) . '.php'; + $value = $this->files->requireOnce($file) ?: []; + if (!$value instanceof Collection) { + $value = collect($value); + } + $this->templateCache[$name] = $value; + } + + return $this->templateCache[$name]; + } + /** * Get the console command options. * diff --git a/src/Generator.php b/src/Generator.php index f12f4e5fd..5bbd27e65 100644 --- a/src/Generator.php +++ b/src/Generator.php @@ -79,7 +79,7 @@ public function __construct( public function generate() { $app = app(); - return $this->view->make('helper') + return $this->view->make('ide-helper::helper') ->with('namespaces_by_extends_ns', $this->getAliasesByExtendsNamespace()) ->with('namespaces_by_alias_ns', $this->getAliasesByAliasNamespace()) ->with('real_time_facades', $this->getRealTimeFacades()) @@ -105,7 +105,7 @@ public function generateEloquent() } $app = app(); - return $this->view->make('helper') + return $this->view->make('ide-helper::helper') ->with('namespaces_by_extends_ns', []) ->with('namespaces_by_alias_ns', ['__root' => [$alias]]) ->with('real_time_facades', []) diff --git a/src/IdeHelperServiceProvider.php b/src/IdeHelperServiceProvider.php index ec5567843..acc8692f2 100644 --- a/src/IdeHelperServiceProvider.php +++ b/src/IdeHelperServiceProvider.php @@ -20,10 +20,6 @@ use Illuminate\Contracts\Support\DeferrableProvider; use Illuminate\Database\Events\MigrationsEnded; use Illuminate\Support\ServiceProvider; -use Illuminate\View\Engines\EngineResolver; -use Illuminate\View\Engines\PhpEngine; -use Illuminate\View\Factory; -use Illuminate\View\FileViewFinder; class IdeHelperServiceProvider extends ServiceProvider implements DeferrableProvider { @@ -65,12 +61,6 @@ public function register() $configPath = __DIR__ . '/../config/ide-helper.php'; $this->mergeConfigFrom($configPath, 'ide-helper'); - $this->app->when([GeneratorCommand::class, MetaCommand::class, ModelsCommand::class]) - ->needs(\Illuminate\Contracts\View\Factory::class) - ->give(function () { - return $this->createLocalViewFactory(); - }); - $this->commands( [ GeneratorCommand::class, @@ -95,20 +85,4 @@ public function provides() EloquentCommand::class, ]; } - - /** - * @return Factory - */ - private function createLocalViewFactory() - { - $resolver = new EngineResolver(); - $resolver->register('php', function () { - return new PhpEngine($this->app['files']); - }); - $finder = new FileViewFinder($this->app['files'], [__DIR__ . '/../resources/views']); - $factory = new Factory($resolver, $finder, $this->app['events']); - $factory->addExtension('php', 'php'); - - return $factory; - } } diff --git a/tests/Console/MetaCommand/MetaCommandTest.php b/tests/Console/MetaCommand/MetaCommandTest.php index fad391eca..116f7ae27 100644 --- a/tests/Console/MetaCommand/MetaCommandTest.php +++ b/tests/Console/MetaCommand/MetaCommandTest.php @@ -27,7 +27,7 @@ public function testCommand(): void $mockFileSystem ->shouldReceive('getRequire') - ->andReturnUsing(function ($__path, $__data) { + ->andReturnUsing(function ($__path, $__data = []) { return (static function () use ($__path, $__data) { extract($__data, EXTR_SKIP); @@ -60,7 +60,7 @@ public function testUnregisterAutoloader(): void $mockFileSystem ->shouldReceive('getRequire') - ->andReturnUsing(function ($__path, $__data) { + ->andReturnUsing(function ($__path, $__data = []) { return (static function () use ($__path, $__data) { extract($__data, EXTR_SKIP);