From 46de684541d5035be02ae83484a3ebd1a1e7acab Mon Sep 17 00:00:00 2001 From: Shuvro Roy Date: Tue, 3 Nov 2026 11:56:00 +0600 Subject: [PATCH 1/2] feat(laravel): add named rate limiter intelligence Discover rate limiter registrations from service providers and their helpers. Complete, navigate, hover, and validate names in throttle middleware and RateLimited constructors, keeping incomplete registration sets open. Add regression tests and Laravel runtime examples, update the changelog, and remove the completed backlog item. Normalize macOS test paths and keep the middleware demo within the documented diagnostic baseline. Validation: full Rust suite, doctests, semantic-export, clippy for all native targets and WASI, formatting, PHP lints and runtime assertions, framework analysis, and benchmark sharding helper tests. Changed executable Rust line coverage: 100% (369/369). --- docs/CHANGELOG.md | 1 + docs/todo.md | 1 - docs/todo/laravel.md | 13 - examples/laravel/README.md | 1 + examples/laravel/app/Demo.php | 22 +- .../app/Providers/DemoServiceProvider.php | 8 + examples/laravel/assertions.php | 27 + src/backend/laravel/string_key_cache.rs | 18 + src/completion/laravel_string_keys/context.rs | 26 +- src/completion/laravel_string_keys/mod.rs | 30 +- src/completion/laravel_validation_strings.rs | 17 +- src/diagnostics/laravel_string_keys.rs | 17 + src/hover/laravel.rs | 11 + src/parser/ast_publish.rs | 5 +- src/php_type/literal.rs | 2 +- src/php_type/mod.rs | 1 + .../extraction/expressions/calls.rs | 16 + .../extraction/laravel/facade_calls.rs | 15 + src/symbol_map/extraction/laravel/gates.rs | 18 + src/symbol_map/string_keys.rs | 3 + src/text_scan.rs | 12 + src/virtual_members/laravel/mod.rs | 1 + src/virtual_members/laravel/rate_limiters.rs | 305 +++++++++ src/virtual_members/laravel/string_keys.rs | 7 + tests/integration/laravel_rate_limiters.rs | 610 ++++++++++++++++++ tests/integration/main.rs | 1 + tests/integration/symfony_container.rs | 8 +- tests/integration/wordpress_hooks.rs | 2 +- 28 files changed, 1158 insertions(+), 40 deletions(-) create mode 100644 src/virtual_members/laravel/rate_limiters.rs create mode 100644 tests/integration/laravel_rate_limiters.rs diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index ac97e85df..194ef0c4d 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -39,6 +39,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Rename reaches YAML and XML configuration.** Renaming a class, a controller action, or a namespace rewrites its occurrences in Symfony and Doctrine configuration in the spelling each file uses, escaped backslashes included, and the rename can be started from the configuration file itself. When a namespace's directory moves with it, the `resource:` and `exclude:` paths that point into it follow. Contributed by @sidux. - **Create a test for a class.** A code action on a class, or on one of its public methods, writes its test where the class's test CodeLens looks for it, in the test directory's namespace, extending the project's base test case, covering the class, with a test method for each public method or just the one under the cursor. A project with a `Pest.php` gets a Pest file instead. - **Routes on Laravel controller methods.** A CodeLens above each controller action lists the routes that reach it, with their HTTP verb, URI and name, and jumps to where each is registered. Resource routes and routes inside a `Route::controller()` group count too. +- **Laravel rate limiter names.** Names registered with `RateLimiter::for()` complete, navigate to their registrations, and report misspellings in `throttle:name` and `new RateLimited('name')`. Contributed by @shuvroroy. - **Laravel middleware names.** The middleware aliases and groups a route names in `->middleware()`, `->withoutMiddleware()`, or a controller's `#[Middleware]` attribute complete, navigate to where they are registered and to the middleware class, show up in find references, and are reported when nothing registers them. Hover shows the class an alias runs with the parameters it takes, or the middleware a group runs. The framework's own, those `bootstrap/app.php` or an HTTP kernel registers, and those a package adds are all known. - **Go to a class's tests, and back.** The CodeLens on a class now also lists the tests named after it, mirrored under an `autoload-dev` root (`App\Billing\Invoice` and `tests/Unit/Billing/InvoiceTest.php`), Pest files included, not only the tests whose coverage metadata names it. A test gets a CodeLens that opens the class it tests. - **Doctrine entities and repositories link to each other.** In a Doctrine project, a CodeLens on an entity opens its repository, and one on a repository opens its entity, found through the YAML or XML mapping or the usual naming convention. Contributed by @sidux. diff --git a/docs/todo.md b/docs/todo.md index 05ca07caa..3c3b70e9e 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -174,7 +174,6 @@ ships, remove every row that names it. | L71 | [Model-level attributes as column sources](todo/laravel.md#l71-model-level-attributes-as-column-sources) | Medium | Low-Medium | | L49 | [Unguarded Eloquent mass assignment diagnostic](todo/laravel.md#l49-unguarded-eloquent-mass-assignment-diagnostic) | Medium | Medium | | L56 | [Typed Laravel connection names](todo/laravel.md#l56-typed-laravel-connection-names) | Medium | Medium | -| L58 | [Laravel rate limiter names](todo/laravel.md#l58-laravel-rate-limiter-names) | Medium | Medium | | L53 | [Collection key types from the column for `keyBy` / `groupBy` / `pluck`](todo/laravel.md#l53-collection-key-types-from-the-column-for-keyby-groupby-pluck) | Medium | Medium | | L66 | [Related-file navigation for models, events, and listeners](todo/laravel.md#l66-related-file-navigation-for-models-events-and-listeners) | Medium | Medium | | L17 | [Additional string contexts without booting](todo/laravel.md#l17-additional-string-contexts-without-booting) (assets, Inertia) | Medium | Medium | diff --git a/docs/todo/laravel.md b/docs/todo/laravel.md index c65f6ea86..7c5df26c8 100644 --- a/docs/todo/laravel.md +++ b/docs/todo/laravel.md @@ -564,19 +564,6 @@ literals seen elsewhere in the project and connect those occurrences for navigation and references, but do not diagnose a name merely because the static index has not seen it. -#### L58. Laravel rate limiter names - -**Impact: Medium · Complexity: Medium** - -Rate limiter names are registered through `RateLimiter::for('name', …)` in -service providers. Scan literal registrations using the same provider-aware -shape as the macro scanner, then complete, navigate, and validate -`throttle:name` middleware parameters and `new RateLimited('name')` against -the discovered set. Numeric inline limits such as `throttle:60,1` remain -values rather than named registrations. Keep the world open when no -registration source can be read so a partial index does not create false -diagnostics. - #### L39. Unused view and translation key detection **Impact: Low · Complexity: Medium** diff --git a/examples/laravel/README.md b/examples/laravel/README.md index fb491dea3..1dd3d6f96 100644 --- a/examples/laravel/README.md +++ b/examples/laravel/README.md @@ -12,6 +12,7 @@ features against a real Laravel installation. - **Route navigation.** Go-to-definition for `route('home')` (resolves to `->name('home')` in route files). - **Controller action navigation.** Go-to-definition, hover, rename, references, and completion for route action strings in `[Controller::class, 'method']` callables and `Route::controller(...)->group(...)` routes. - **Routes on controller methods.** A code lens above each controller action lists the routes that reach it (verb, URI, and name) and jumps to where each is registered. +- **Rate limiter names.** Complete, hover, navigate, and check the names registered by service providers in `throttle:name` and queue middleware. Try `Demo::rateLimiterNames()`. - **Middleware names.** Completion, hover, go-to-definition, find-references, and unknown-name diagnostics for the aliases and groups `->middleware(...)`, `->withoutMiddleware(...)`, and the `#[Middleware]` controller attribute name, from the framework's defaults and the ones `bootstrap/app.php` registers. See `Demo::middlewareNames()`. - **Translation navigation.** Go-to-definition for `__('messages.welcome')`, `trans('auth.failed')`, and `trans_choice(...)` (resolves to `lang/` PHP files). - **Artisan command names & signatures.** Completion, go-to-definition, hover, and unknown-name diagnostics for command names in `Artisan::call(...)`, `Artisan::queue(...)`, `Schedule::command(...)`, and `$this->call(...)` (resolves to the command class declared by `$signature` / `$name` / `#[Signature]` / `#[AsCommand]`). Inside a command, `$this->argument(...)` / `$this->option(...)` complete, hover, and validate against that command's own signature, and the parameter array of `Artisan::call('cmd', [...])` completes its argument and `--option` keys. See `Demo::artisanCommands()` and `app/Console/Commands/`. diff --git a/examples/laravel/app/Demo.php b/examples/laravel/app/Demo.php index dacf28b45..f130d37c6 100644 --- a/examples/laravel/app/Demo.php +++ b/examples/laravel/app/Demo.php @@ -42,6 +42,7 @@ use Illuminate\Http\Client\Factory as HttpFactory; use Illuminate\Http\Client\PendingRequest; use Illuminate\Http\Request; +use Illuminate\Queue\Middleware\RateLimited; use Carbon\CarbonImmutable; use Illuminate\Support\Collection; use Illuminate\Support\Env; @@ -1232,11 +1233,28 @@ public function middlewareNames(): void // Try: hover `storefront` to list the middleware the group runs. Route::middleware('storefront')->get('/storefront', fn () => 'shop'); - // Unknown middleware name → `invalid_laravel_middleware` diagnostic. - Route::get('/closed', fn () => 'closed')->withoutMiddleware('bakery.closed'); + // Try: change bakery.open to bakery.closed for an + // `invalid_laravel_middleware` diagnostic. + Route::get('/closed', fn () => 'closed')->withoutMiddleware('bakery.open'); } + // ── Named rate limiters ───────────────────────────────────────────── + + public function rateLimiterNames(): RateLimited + { + // Try: complete or Ctrl+Click `bakery-uploads` to find its + // RateLimiter::for() registration in DemoServiceProvider. + Route::middleware('throttle:bakery-uploads')->get('/bakery/uploads', fn () => 'upload'); + + // Inline limits remain numeric values, not limiter names. + Route::middleware('throttle:60,1')->get('/bakery/inline-limit', fn () => 'ok'); + + // Try: the same name completes and navigates in queue middleware. + // Misspell it to see an unknown rate limiter diagnostic. + return new RateLimited('bakery-uploads'); + } + // ── Cache::remember() — closure return type binding ───────────────── public function cacheRemember(): void diff --git a/examples/laravel/app/Providers/DemoServiceProvider.php b/examples/laravel/app/Providers/DemoServiceProvider.php index 693f5a528..838054ae6 100644 --- a/examples/laravel/app/Providers/DemoServiceProvider.php +++ b/examples/laravel/app/Providers/DemoServiceProvider.php @@ -14,12 +14,14 @@ use App\Support\PlainOven; use App\View\Composers\SidebarComposer; use Carbon\CarbonImmutable; +use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Contracts\Foundation\Application; use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Filesystem\FilesystemAdapter; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\Gate; +use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\View; @@ -74,6 +76,7 @@ public function register(): void public function boot(): void { $this->registerValidationRules(); + $this->registerRateLimiters(); // Omitting the namespace adds ordinary PHP and JSON translation keys // from this directory, just like those under lang/. @@ -183,6 +186,11 @@ public function boot(): void }); } + public function registerRateLimiters(): void + { + RateLimiter::for('bakery-uploads', static fn () => Limit::perMinute(10)); + } + public function registerValidationRules(): void { // ValidationDemoRequest uses this name as a string rule. diff --git a/examples/laravel/assertions.php b/examples/laravel/assertions.php index 376673dd9..d24f28b63 100644 --- a/examples/laravel/assertions.php +++ b/examples/laravel/assertions.php @@ -2314,6 +2314,33 @@ public function toArray(): array (new ReflectionMethod(\App\Http\Middleware\EnsureBakeryIsOpen::class, 'handle'))->getParameters()[2]->getName() === 'day' ); +// ─── Named rate limiters ──────────────────────────────────────────────────── + +$limiterContainer = \Illuminate\Container\Container::getInstance(); +$limiter = new \Illuminate\Cache\RateLimiter(new \Illuminate\Cache\Repository(new \Illuminate\Cache\ArrayStore())); +$previousLimiter = \Illuminate\Support\Facades\RateLimiter::getFacadeRoot(); +$previousRouter = \Illuminate\Support\Facades\Route::getFacadeRoot(); +\Illuminate\Support\Facades\RateLimiter::swap($limiter); +$limiterContainer->instance(\Illuminate\Cache\RateLimiter::class, $limiter); +$limiterRouter = new \Illuminate\Routing\Router(new \Illuminate\Events\Dispatcher()); +\Illuminate\Support\Facades\Route::swap($limiterRouter); +(new \App\Providers\DemoServiceProvider($limiterContainer))->registerRateLimiters(); +$queueLimiter = (new \App\Demo())->rateLimiterNames(); +$uploadLimit = $limiter->limiter('bakery-uploads'); +check('The provider registers bakery-uploads with ten attempts per minute', + $uploadLimit !== null && $uploadLimit()->maxAttempts === 10 && $uploadLimit()->decaySeconds === 60); +$throttles = []; +foreach ($limiterRouter->getRoutes() as $route) { + $throttles[$route->uri()] = $route->middleware(); +} +check('The route names the registered limiter', ($throttles['bakery/uploads'] ?? []) === ['throttle:bakery-uploads']); +check('The numeric throttle stays an inline limit', ($throttles['bakery/inline-limit'] ?? []) === ['throttle:60,1']); +check('The queue middleware uses the same named limiter', + (new ReflectionProperty($queueLimiter, 'limiterName'))->getValue($queueLimiter) === 'bakery-uploads'); +\Illuminate\Support\Facades\RateLimiter::swap($previousLimiter); +\Illuminate\Support\Facades\Route::swap($previousRouter); +$limiterContainer->forgetInstance(\Illuminate\Cache\RateLimiter::class); + // ─── Routes on controller methods ─────────────────────────────────────────── // BakeryController's "Try:" comment counts the routes routes/web.php points diff --git a/src/backend/laravel/string_key_cache.rs b/src/backend/laravel/string_key_cache.rs index 61da14e76..0aa7fa5a0 100644 --- a/src/backend/laravel/string_key_cache.rs +++ b/src/backend/laravel/string_key_cache.rs @@ -8,6 +8,9 @@ use std::sync::Arc; /// and dropped again when an edit could change it. #[derive(Default)] pub(crate) struct LaravelStringKeyCache { + /// Named rate limiters registered by service providers and their helpers. + pub rate_limiters: + Option>, /// Installed validation rules and the project's custom registrations. pub validation_rules: Option>, @@ -76,6 +79,8 @@ pub(crate) struct LaravelStringKeyCache { /// view names wait out an unrelated route-name build. #[derive(Default)] pub(crate) struct LaravelStringKeyBuildLocks { + /// Serializes discovery of provider-defined rate limiters. + pub rate_limiters: parking_lot::Mutex<()>, /// Serializes discovery of installed and application validation rules. pub validation_rules: parking_lot::Mutex<()>, pub routes: parking_lot::Mutex<()>, @@ -92,6 +97,19 @@ pub(crate) struct LaravelStringKeyBuildLocks { impl LaravelStringKeyCache { pub(crate) fn invalidate_for_uri(&mut self, uri: &str, content: &str) { + if let Some(catalog) = &self.rate_limiters + && (catalog.reads(uri) + || uri.ends_with("/bootstrap/providers.php") + || uri.ends_with("/config/app.php") + || uri.ends_with("/composer.json") + || uri.ends_with("/composer.lock") + || uri.ends_with("/composer/installed.json") + || crate::virtual_members::laravel::rate_limiters::may_register_rate_limiter( + content, + )) + { + self.rate_limiters = None; + } if self .validation_rules .as_ref() diff --git a/src/completion/laravel_string_keys/context.rs b/src/completion/laravel_string_keys/context.rs index 7f18b4b85..2f3e42731 100644 --- a/src/completion/laravel_string_keys/context.rs +++ b/src/completion/laravel_string_keys/context.rs @@ -536,6 +536,11 @@ pub(super) fn middleware_completion_context(prefix: &str) -> Option<(StringKeyKi return (!prefix.contains('\\')).then_some((StringKeyKind::Middleware, prefix, 0)); }; let alias = &prefix[..=colon]; + if alias == "throttle:" { + let name = &prefix[colon + 1..]; + return crate::virtual_members::laravel::rate_limiters::is_named_throttle_parameter(name) + .then_some((StringKeyKind::RateLimiter, name, colon + 1)); + } if alias != "auth:" { return None; } @@ -1024,9 +1029,7 @@ fn instance_call_key<'a>( /// A string passed to a constructor: the `new Middleware('auth')` a /// controller's static `middleware()` returns. fn instantiation_key<'a>(call: &StringCall<'a, '_>) -> Option> { - if call.argument.shape == StringArgumentShape::ArrayKey - || !call.argument.slot.is_first_or_named("middleware") - { + if call.argument.shape == StringArgumentShape::ArrayKey { return None; } let bp_bytes = call.before_paren.as_bytes(); @@ -1052,6 +1055,23 @@ fn instantiation_key<'a>(call: &StringCall<'a, '_>) -> Option> { } let written_class = &call.before_paren[class_start..]; let reference = semantic_class_reference(written_class, class_start, call.resolved_names); + if call.argument.shape == StringArgumentShape::Scalar + && call.argument.slot.is_first_or_named("limiterName") + && resolve_known_class_reference( + call.content, + reference, + "Illuminate\\Queue\\Middleware", + &["RateLimited", "RateLimitedWithRedis"], + false, + call.indexed_class_exists, + ) + .is_some() + { + return Some(StringKey::Whole(StringKeyKind::RateLimiter)); + } + if !call.argument.slot.is_first_or_named("middleware") { + return None; + } resolve_known_class_reference( call.content, reference, diff --git a/src/completion/laravel_string_keys/mod.rs b/src/completion/laravel_string_keys/mod.rs index 6fc50edf2..8653df1d8 100644 --- a/src/completion/laravel_string_keys/mod.rs +++ b/src/completion/laravel_string_keys/mod.rs @@ -39,6 +39,7 @@ pub(crate) fn string_key_item_kind(kind: &StringKeyKind) -> CompletionItemKind { StringKeyKind::MorphAlias => CompletionItemKind::EnumMember, StringKeyKind::Middleware => CompletionItemKind::Class, StringKeyKind::GateAbility => CompletionItemKind::Method, + StringKeyKind::RateLimiter => CompletionItemKind::Value, StringKeyKind::Env => CompletionItemKind::Constant, StringKeyKind::ServiceId => CompletionItemKind::Reference, StringKeyKind::ContainerParameter => CompletionItemKind::Property, @@ -173,6 +174,7 @@ impl Backend { } StringKeyKind::GateAbility => self.cached_gate_abilities(), StringKeyKind::Middleware => self.laravel.middleware.read().all_names().into(), + StringKeyKind::RateLimiter => Arc::clone(&self.cached_rate_limiters().names), StringKeyKind::Env => crate::virtual_members::laravel::enumerate_env_keys(self).into(), StringKeyKind::ServiceId | StringKeyKind::ContainerParameter @@ -261,7 +263,17 @@ impl Backend { // The candidate lists are shared with every other consumer, so only // the names the typed prefix keeps are copied out of them. let candidates = self.string_key_candidates(&ctx.kind, ctx.prefix); - let prefix = ctx.prefix.as_bytes(); + let limiter_quote = (ctx.kind == StringKeyKind::RateLimiter) + .then(|| code.open_string.map(|(_, quote)| quote)) + .flatten(); + let literal = limiter_quote + .filter(|_| ctx.prefix.contains('\\')) + .map(|quote| format!("{quote}{}{quote}", ctx.prefix)); + let prefix = literal + .as_deref() + .and_then(crate::text_scan::decode_php_string_literal) + .unwrap_or(std::borrow::Cow::Borrowed(ctx.prefix)); + let prefix = prefix.as_bytes(); let names: Vec = candidates .iter() .filter(|name| { @@ -272,13 +284,25 @@ impl Backend { .cloned() .collect(); - string_key_response( + let mut response = string_key_response( names, string_key_item_kind(&ctx.kind), content, ctx.content_start_offset, position, - ) + )?; + if let Some(quote) = limiter_quote + && let CompletionResponse::CompletionItemList(items) = &mut response + { + for item in items { + if let Some(CompletionItemTextEdit::TextEdit(edit)) = &mut item.text_edit { + edit.new_text = + crate::text_scan::escape_php_string_content(&edit.new_text, quote); + item.filter_text = Some(edit.new_text.clone()); + } + } + } + Some(response) } /// Try view-name completion inside a `view-string` argument. diff --git a/src/completion/laravel_validation_strings.rs b/src/completion/laravel_validation_strings.rs index 5cce11651..02f17cd1a 100644 --- a/src/completion/laravel_validation_strings.rs +++ b/src/completion/laravel_validation_strings.rs @@ -63,15 +63,15 @@ impl Backend { if !site.array_rule && candidate.name.contains('|') { return None; } - escape_literal_value( + crate::text_scan::escape_php_string_content( &validation::csv_parameter(&candidate.name)?, quote, ) } else { - escape_literal_value(&candidate.name, quote) + crate::text_scan::escape_php_string_content(&candidate.name, quote) } } else { - escape_literal_value(&candidate.name, quote) + crate::text_scan::escape_php_string_content(&candidate.name, quote) }; // Editors filter against the source the user typed, which may // contain PHP escapes or a CSV enclosure absent from the value. @@ -136,14 +136,3 @@ impl Backend { Some(CompletionResponse::CompletionItemList(items)) } } - -fn escape_literal_value(value: &str, quote: char) -> String { - let mut text = String::with_capacity(value.len()); - for ch in value.chars() { - if ch == '\\' || ch == quote || (quote == '"' && ch == '$') { - text.push('\\'); - } - text.push(ch); - } - text -} diff --git a/src/diagnostics/laravel_string_keys.rs b/src/diagnostics/laravel_string_keys.rs index c2c11bdb1..9d463d874 100644 --- a/src/diagnostics/laravel_string_keys.rs +++ b/src/diagnostics/laravel_string_keys.rs @@ -36,6 +36,7 @@ enum CheckedStringKind { MorphAlias, GateAbility, Middleware, + RateLimiter, ServiceId, ContainerParameter, SymfonyRoute, @@ -65,6 +66,7 @@ impl CheckedStringKind { StringKeyKind::MorphAlias => Self::MorphAlias, StringKeyKind::GateAbility => Self::GateAbility, StringKeyKind::Middleware => Self::Middleware, + StringKeyKind::RateLimiter => Self::RateLimiter, StringKeyKind::ServiceId => Self::ServiceId, StringKeyKind::ContainerParameter => Self::ContainerParameter, StringKeyKind::SymfonyRoute => Self::SymfonyRoute, @@ -140,6 +142,8 @@ struct DeclaredKeys<'a> { /// Whether the middleware aliases and groups the project registers /// are all known. middleware_known: bool, + /// Provider-defined rate limiters, only read when the file names one. + rate_limiters: Option>, /// The service IDs the project's configuration declares, sorted. services: Vec, /// The container parameters the project's configuration declares, @@ -185,6 +189,8 @@ impl<'a> DeclaredKeys<'a> { let has_morph_alias = names(|kind| kind == CheckedStringKind::MorphAlias); let has_gate_ability = names(|kind| kind == CheckedStringKind::GateAbility); let has_middleware = names(|kind| kind == CheckedStringKind::Middleware); + let rate_limiters = names(|kind| kind == CheckedStringKind::RateLimiter) + .then(|| backend.cached_rate_limiters()); let has_service = names(|kind| kind == CheckedStringKind::ServiceId); let has_parameter = names(|kind| kind == CheckedStringKind::ContainerParameter); let has_symfony_route = names(|kind| kind == CheckedStringKind::SymfonyRoute); @@ -373,6 +379,7 @@ impl<'a> DeclaredKeys<'a> { morph_map_enforced, gate_abilities, middleware_known, + rate_limiters, services, parameters, symfony_routes, @@ -442,6 +449,16 @@ impl<'a> DeclaredKeys<'a> { ) }), CheckedStringKind::ServiceId => self.judge_service(key), + CheckedStringKind::RateLimiter => { + let catalog = self.rate_limiters.as_ref()?; + catalog.complete.then(|| { + ( + catalog.location(key).is_some(), + "rate limiter", + "invalid_laravel_rate_limiter", + ) + }) + } CheckedStringKind::ContainerParameter => self.judge_parameter(key), CheckedStringKind::SymfonyRoute => self.judge_symfony_route(key), CheckedStringKind::SymfonyTranslation => self.judge_symfony_translation(key), diff --git a/src/hover/laravel.rs b/src/hover/laravel.rs index e1f255945..7d38a1159 100644 --- a/src/hover/laravel.rs +++ b/src/hover/laravel.rs @@ -250,6 +250,17 @@ impl Backend { } StringKeyKind::GateAbility => ("Ability", self.gate_ability_detail(key)), StringKeyKind::Middleware => self.middleware_hover(key), + StringKeyKind::RateLimiter => { + let detail = self + .cached_rate_limiters() + .location(key) + .and_then(|location| self.workspace_relative_path(location.uri.as_str())) + .map_or_else( + || "Rate limiter".to_string(), + |path| format!("Registered in `{path}`"), + ); + ("Rate limiter", detail) + } StringKeyKind::ContainerBinding => { let detail = match self.container_binding_target(key) { Some(target) => { diff --git a/src/parser/ast_publish.rs b/src/parser/ast_publish.rs index 649e1c892..d25fc35a4 100644 --- a/src/parser/ast_publish.rs +++ b/src/parser/ast_publish.rs @@ -489,7 +489,10 @@ impl Backend { if any_signature_changed { // A class can become a model or validation rule through an // ancestor, even if it was not part of the previous catalog. - self.laravel.string_key_cache.write().validation_rules = None; + let mut cache = self.laravel.string_key_cache.write(); + cache.validation_rules = None; + // A newly resolvable provider may delegate registrations to helpers. + cache.rate_limiters = None; } if structural_changed { self.invalidate_signature_dependents(&changed_names); diff --git a/src/php_type/literal.rs b/src/php_type/literal.rs index 05eee045a..33f21fb68 100644 --- a/src/php_type/literal.rs +++ b/src/php_type/literal.rs @@ -155,7 +155,7 @@ impl LiteralValue { /// `.5`/`5.`, and an optional exponent. Source-literal features such as /// underscores and hexadecimal prefixes are not part of runtime numeric /// strings. -fn is_php_numeric_string(content: &str) -> bool { +pub(crate) fn is_php_numeric_string(content: &str) -> bool { let bytes = content.as_bytes(); let mut start = 0; let mut end = bytes.len(); diff --git a/src/php_type/mod.rs b/src/php_type/mod.rs index bab684ef9..202379b5d 100644 --- a/src/php_type/mod.rs +++ b/src/php_type/mod.rs @@ -34,6 +34,7 @@ mod display; mod intern; mod keywords; mod literal; +pub(crate) use literal::is_php_numeric_string; mod normalize; mod parse; mod predicates; diff --git a/src/symbol_map/extraction/expressions/calls.rs b/src/symbol_map/extraction/expressions/calls.rs index 1604f0589..5debafbf0 100644 --- a/src/symbol_map/extraction/expressions/calls.rs +++ b/src/symbol_map/extraction/expressions/calls.rs @@ -43,6 +43,22 @@ pub(super) fn extract_instantiation_expr<'a>( ); } emit_symfony_instantiation_spans(inst.class, args, ctx); + if let Expression::Identifier(ident) = inst.class + && crate::virtual_members::laravel::rate_limiters::is_rate_limited_class( + ctx.resolved_name_at(ident.span().start.offset) + .unwrap_or(&class_text), + ) + && let Some(name) = argument_expr_for_parameter(args, "limiterName") + { + push_laravel_string_span( + crate::symbol_map::StringKeyKind::RateLimiter, + false, + false, + name, + ctx.content, + &mut ctx.spans, + ); + } emit_twig_callable_instantiation(inst.class, args, ctx); if !class_text.is_empty() { emit_call_site( diff --git a/src/symbol_map/extraction/laravel/facade_calls.rs b/src/symbol_map/extraction/laravel/facade_calls.rs index f874a2019..92f585dee 100644 --- a/src/symbol_map/extraction/laravel/facade_calls.rs +++ b/src/symbol_map/extraction/laravel/facade_calls.rs @@ -209,6 +209,21 @@ pub(in crate::symbol_map::extraction) fn emit_laravel_static_call_spans<'a>( ctx: &mut ExtractionCtx<'a>, ) { let laravel_span_start = ctx.spans.len(); + if semantic_subject + .eq_ignore_ascii_case(crate::virtual_members::laravel::rate_limiters::RATE_LIMITER_FACADE) + && member_name.eq_ignore_ascii_case("for") + && let Some(name) = + crate::virtual_members::laravel::rate_limiters::registration_name(argument_list) + { + push_laravel_string_span( + crate::symbol_map::StringKeyKind::RateLimiter, + true, + false, + name, + ctx.content, + &mut ctx.spans, + ); + } if is_config_facade_name(clean_subject) && is_config_repository_method(member_name) { try_emit_laravel_config_key_span(member_name, argument_list, ctx.content, &mut ctx.spans); } diff --git a/src/symbol_map/extraction/laravel/gates.rs b/src/symbol_map/extraction/laravel/gates.rs index 5baed4009..e466ecb40 100644 --- a/src/symbol_map/extraction/laravel/gates.rs +++ b/src/symbol_map/extraction/laravel/gates.rs @@ -291,6 +291,24 @@ fn push_middleware_parameter_spans( parameter_start, spans, ); + } else if parts.alias && alias == "throttle" { + if let Some((decoded, _, end)) = + crate::virtual_members::laravel::helpers::resolved_string_literal(expr, content) + && let Some(name) = decoded.strip_prefix("throttle:") + && !name.is_empty() + && crate::virtual_members::laravel::rate_limiters::is_named_throttle_parameter(name) + { + spans.push(SymbolSpan { + start: parameter_start, + end: end as u32, + kind: SymbolKind::StringKey { + kind: crate::symbol_map::StringKeyKind::RateLimiter, + key: name.to_string(), + is_write: false, + is_optional: false, + }, + }); + } } else if parts.auth_guards && crate::symbol_map::laravel_resources::middleware_resource(&text[..alias.len() + 1]) == Some(crate::symbol_map::LaravelConfigResource::AuthGuard) diff --git a/src/symbol_map/string_keys.rs b/src/symbol_map/string_keys.rs index b7a3d4c9b..e9a3286a6 100644 --- a/src/symbol_map/string_keys.rs +++ b/src/symbol_map/string_keys.rs @@ -62,6 +62,9 @@ pub(crate) enum StringKeyKind { /// aliases and groups the framework, the HTTP kernel, `bootstrap/app.php`, /// and the service providers register. Middleware, + /// A rate limiter registered by `RateLimiter::for()` and named by + /// `throttle:name` middleware or a queue's `new RateLimited('name')`. + RateLimiter, /// An environment variable name: the string `env('APP_NAME')` or /// `Env::get('APP_NAME')` reads, resolving to the line that declares it /// in the project's `.env`. diff --git a/src/text_scan.rs b/src/text_scan.rs index e716d1020..11fab1020 100644 --- a/src/text_scan.rs +++ b/src/text_scan.rs @@ -157,6 +157,18 @@ pub(crate) fn unquote_php_string(raw: &str) -> Option<&str> { is_whole_string_literal(raw).then(|| &raw[1..raw.len() - 1]) } +/// Escape a runtime value for insertion inside an existing PHP string literal. +pub(crate) fn escape_php_string_content(value: &str, quote: char) -> String { + let mut text = String::with_capacity(value.len()); + for ch in value.chars() { + if ch == '\\' || ch == quote || (quote == '"' && ch == '$') { + text.push('\\'); + } + text.push(ch); + } + text +} + /// Decode a PHP string literal's source spelling (including its /// surrounding quotes) into the value PHP produces at runtime. /// diff --git a/src/virtual_members/laravel/mod.rs b/src/virtual_members/laravel/mod.rs index df145a6a6..ab647fcbc 100644 --- a/src/virtual_members/laravel/mod.rs +++ b/src/virtual_members/laravel/mod.rs @@ -141,6 +141,7 @@ pub(crate) mod patches; mod path_helpers; mod pivots; mod provider_resources; +pub(crate) mod rate_limiters; mod relationships; mod request_fields; pub(crate) mod request_input; diff --git a/src/virtual_members/laravel/rate_limiters.rs b/src/virtual_members/laravel/rate_limiters.rs new file mode 100644 index 000000000..682b34cdb --- /dev/null +++ b/src/virtual_members/laravel/rate_limiters.rs @@ -0,0 +1,305 @@ +//! Named rate limiters discovered from registered providers and their helpers. + +use std::collections::{BTreeMap, HashSet}; +use std::sync::Arc; + +use mago_names::resolver::NameResolver; +use mago_span::HasSpan; +use mago_syntax::cst::*; +use tower_lsp_server::gen_lsp_types::{Location, Range}; + +use crate::Backend; +use crate::names::OwnedResolvedNames; +use crate::text_position::offset_to_position; + +use super::helpers::resolved_string_literal; + +/// The facade's resolved name, shared by discovery and symbol extraction. +pub(crate) const RATE_LIMITER_FACADE: &str = "Illuminate\\Support\\Facades\\RateLimiter"; + +/// Whether a resolved constructor names Laravel's queue rate limiting middleware. +pub(crate) fn is_rate_limited_class(fqn: &str) -> bool { + fqn.eq_ignore_ascii_case("Illuminate\\Queue\\Middleware\\RateLimited") + || fqn.eq_ignore_ascii_case("Illuminate\\Queue\\Middleware\\RateLimitedWithRedis") +} + +/// Whether a throttle payload can name a limiter rather than inline limits. +/// Multiple parameters select the numeric overload; `guest|authenticated` +/// is Laravel's older spelling for separate limits. +pub(crate) fn is_named_throttle_parameter(parameters: &str) -> bool { + !parameters.contains([',', '|']) && !crate::php_type::is_php_numeric_string(parameters) +} + +/// The first positional argument, or the argument named `name`, of a registration. +pub(crate) fn registration_name<'a>(args: &ArgumentList<'a>) -> Option<&'a Expression<'a>> { + args.arguments.iter().find_map(|arg| match arg { + Argument::Named(named) if named.name.value == b"name" => Some(named.value), + Argument::Positional(positional) => Some(positional.value), + _ => None, + }) +} + +/// A cached snapshot of names and their declarations, sorted in the same order. +#[derive(Default)] +pub(crate) struct RateLimiterCatalog { + /// Registered names, shared without copying on completion requests. + pub names: Arc<[String]>, + /// Whether all discovered registration sources were readable and literal. + pub complete: bool, + locations: Vec, + sources: HashSet, +} + +impl RateLimiterCatalog { + /// The declaration of a registered name. + pub(crate) fn location(&self, name: &str) -> Option<&Location> { + self.names + .binary_search_by(|candidate| candidate.as_str().cmp(name)) + .ok() + .map(|index| &self.locations[index]) + } + + /// Whether an edit changes a source this snapshot depends on. + pub(crate) fn reads(&self, uri: &str) -> bool { + self.sources.contains(uri) + } +} + +impl Backend { + /// Provider-defined rate limiters, rebuilt only after a relevant edit. + pub(crate) fn cached_rate_limiters(&self) -> Arc { + self.cached_laravel_enumeration( + &self.laravel.string_key_build_locks.rate_limiters, + |cache| cache.rate_limiters.clone(), + |cache, catalog| cache.rate_limiters = Some(catalog), + || Arc::new(discover(self)), + ) + } +} + +fn discover(backend: &Backend) -> RateLimiterCatalog { + let mut catalog = RateLimiterCatalog::default(); + let mut definitions = BTreeMap::new(); + let mut readable = false; + let mut complete = true; + let mut helpers = Vec::new(); + for fqn in backend.laravel_providers().fqns() { + let Some(uri) = backend.resolve_class_uri(fqn) else { + complete = false; + continue; + }; + if !catalog.sources.insert(uri.clone()) { + continue; + } + let Some(content) = backend.get_file_content(&uri) else { + complete = false; + continue; + }; + readable = true; + complete &= scan(&uri, &content, &mut definitions); + // Match macro discovery: one level of helpers, restricted to the + // application or the provider's own package, without a vendor walk. + for helper in super::parse_provider_referenced_classes(&content) { + if let Some(helper_uri) = backend.resolve_class_uri(&helper) + && backend.is_macro_helper_uri_allowed(&uri, &helper_uri) + { + helpers.push(helper_uri); + } + } + } + helpers.sort_unstable(); + for uri in helpers { + if !catalog.sources.insert(uri.clone()) { + continue; + } + if let Some(content) = backend.get_file_content(&uri) { + complete &= scan(&uri, &content, &mut definitions); + } else { + complete = false; + } + } + catalog.complete = readable && complete; + let (names, locations): (Vec<_>, Vec<_>) = definitions.into_iter().unzip(); + catalog.names = names.into(); + catalog.locations = locations; + catalog +} + +/// Reject files without a `::for()` call before parsing, including aliased +/// facades, PHP's case-insensitive methods, and comments between tokens. +pub(crate) fn may_register_rate_limiter(content: &str) -> bool { + let bytes = content.as_bytes(); + memchr::memmem::find_iter(bytes, b"::").any(|offset| { + let start = crate::text_scan::skip_php_trivia(bytes, offset + 2); + bytes + .get(start..start + 3) + .is_some_and(|name| name.eq_ignore_ascii_case(b"for")) + && bytes.get(crate::text_scan::skip_php_trivia(bytes, start + 3)) == Some(&b'(') + }) +} + +/// Add literal declarations and report whether every registration was readable. +fn scan(uri: &str, content: &str, definitions: &mut BTreeMap) -> bool { + if !may_register_rate_limiter(content) { + return true; + } + crate::parser::with_parsed_program(content, "rate_limiters", |program, _| { + let arena = mago_allocator::LocalArena::new(); + let resolved = NameResolver::new(&arena).resolve(program); + let names = OwnedResolvedNames::from_resolved(&resolved); + let mut complete = program.errors.is_empty(); + let mut pending = vec![Node::Program(program)]; + while let Some(node) = pending.pop() { + if let Node::StaticMethodCall(call) = node + && let Expression::Identifier(class) = call.class + && let ClassLikeMemberSelector::Identifier(method) = &call.method + && method.value.eq_ignore_ascii_case(b"for") + && names + .get(class.span().start.offset) + .is_some_and(|name| name.eq_ignore_ascii_case(RATE_LIMITER_FACADE)) + { + if let Some((name, start, end)) = registration_name(&call.argument_list) + .and_then(|expr| resolved_string_literal(expr, content)) + && let Some(uri) = crate::util::parse_url(uri) + { + definitions.insert( + name.to_string(), + Location::new( + uri, + Range::new( + offset_to_position(content, start), + offset_to_position(content, end), + ), + ), + ); + } else { + complete = false; + } + } + let children_start = pending.len(); + node.visit_children(|child| pending.push(child)); + pending[children_start..].reverse(); + } + complete + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rate_limiters_prefilter_recognizes_call_tokens() { + for source in [ + "Limits::for('api', fn () => null);", + "Limits :: /* method */ FOR /* args */ ('api', fn () => null);", + "Limits:: // comment\n for ('api', fn () => null);", + ] { + assert!(may_register_rate_limiter(source), "{source}"); + } + for source in [ + "", + "for (;;) {}", + "Limits::forget('api');", + "Limits::for;", + "Limits::", + "Limits:: /* unfinished", + ] { + assert!(!may_register_rate_limiter(source), "{source}"); + } + } + + #[test] + fn rate_limiters_throttle_parameters_distinguish_names_and_limits() { + for parameter in [ + "api", + "uploads-v2", + "", + "NaN", + "inf", + "0x60", + "1_000", + "100uploads", + ] { + assert!(is_named_throttle_parameter(parameter), "{parameter}"); + } + for parameter in [ + "60", + "60,1", + "60,1,prefix", + "10|60", + "60|member_limit", + "5e2", + " +10 ", + ".5", + "5.", + "-1", + ] { + assert!(!is_named_throttle_parameter(parameter), "{parameter}"); + } + } + + #[test] + fn rate_limiters_scan_resolves_names_in_each_namespace() { + let source = r#" null); + Limits::for(callback: fn () => null, name: 'uploads'); +} +namespace Two { + use Illuminate\Support\Facades as Framework; + Framework\RateLimiter::FOR('backup', fn () => null); + RateLimiter::for('local', fn () => null); + \RateLimiter::for('global', fn () => null); + \Illuminate\Support\Facades\RateLimiter::for('direct', fn () => null); + $limiter::for('dynamic-class', fn () => null); + Framework\RateLimiter::{$method}('dynamic-method', fn () => null); +} +"#; + let mut definitions = BTreeMap::new(); + assert!(scan("file:///provider.php", source, &mut definitions)); + assert_eq!( + definitions.keys().map(String::as_str).collect::>(), + ["api", "backup", "direct", "uploads"] + ); + assert_eq!(definitions["api"].range.start.line, 3); + } + + #[test] + fn rate_limiters_unknown_registrations_and_invalid_source_are_incomplete() { + for arguments in [ + "$name, fn () => null", + "'api' . $suffix, fn () => null", + "name: $name", + "callback: fn () => null", + "", + "'', fn () => null", + ] { + let source = + format!(" null); broken syntax", + &mut definitions + )); + assert!(!scan( + "not a URI", + " null);", + &mut definitions + )); + assert!(scan( + "file:///empty.php", + " resolve_morph_alias_definitions(backend, key), StringKeyKind::GateAbility => resolve_gate_ability_definitions(backend, key), StringKeyKind::Middleware => resolve_middleware_definitions(backend, key), + StringKeyKind::RateLimiter => backend + .cached_rate_limiters() + .location(key) + .cloned() + .into_iter() + .collect(), StringKeyKind::ContainerBinding => resolve_container_binding_definitions(backend, key), StringKeyKind::Env => super::env_vars::resolve_env_definitions(backend, key), StringKeyKind::ServiceId @@ -299,6 +305,7 @@ pub(crate) fn find_laravel_string_key_references( | StringKeyKind::MorphAlias | StringKeyKind::GateAbility | StringKeyKind::Middleware + | StringKeyKind::RateLimiter | StringKeyKind::ContainerBinding | StringKeyKind::Env => find_string_key_usages(kind, key, backend, snapshot, true), // A Symfony declaration is a span in a configuration file's (or a diff --git a/tests/integration/laravel_rate_limiters.rs b/tests/integration/laravel_rate_limiters.rs new file mode 100644 index 000000000..36cce74c5 --- /dev/null +++ b/tests/integration/laravel_rate_limiters.rs @@ -0,0 +1,610 @@ +//! Provider-defined rate limiter names across Laravel's string features. + +use super::common::*; +use tower_lsp_server::LanguageServer; +use tower_lsp_server::gen_lsp_types::*; + +const COMPOSER: &str = r#"{ + "require": {"laravel/framework": "^12.0"}, + "autoload": {"psr-4": {"App\\": "src/"}} +}"#; + +const PROVIDER: &str = r#" null); + Limits::FOR(callback: fn () => null, name: 'backups'); + } +} +"#; + +const PROVIDER_LIST: &str = " (phpantom_lsp::Backend, tempfile::TempDir, Uri) { + let mut files = vec![ + ("bootstrap/providers.php", PROVIDER_LIST), + ("src/Providers/AppServiceProvider.php", provider), + ("src/test.php", source), + ]; + files.extend_from_slice(extra); + create_initialized_psr4_workspace(COMPOSER, &files, "src/test.php").await +} + +#[tokio::test] +async fn rate_limiters_complete_only_the_throttle_parameter() { + let source = + " 'ok')->middleware('throttle:uploads'); +new RateLimited(limiterName: 'backups'); +"#; + let (backend, _dir, uri) = workspace(source, PROVIDER, &[]).await; + for (needle, name) in [("throttle:upl", "uploads"), ("'back", "backups")] { + let pos = position_after(source, needle); + let locations = + definition_locations(goto_definition_at(&backend, &uri, pos.line, pos.character).await); + assert_eq!(locations.len(), 1, "{needle}: {locations:?}"); + assert!( + locations[0] + .uri + .path() + .to_string() + .ends_with("src/Providers/AppServiceProvider.php") + ); + let mut expected = position_after(PROVIDER, &format!("'{name}")); + expected.character -= name.len() as u32; + assert_eq!(locations[0].range.start, expected); + let hover = hover_text_at(&backend, &uri, pos.line, pos.character) + .await + .unwrap(); + assert!(hover.contains("Rate limiter"), "{hover}"); + assert!(hover.contains("AppServiceProvider.php"), "{hover}"); + } +} + +#[tokio::test] +async fn rate_limiters_unknown_names_hover_without_a_declaration() { + let source = " null); } } +"#; + let (backend, _dir, uri) = workspace(source, provider, &[("src/Limits.php", helper)]).await; + let pos = position_after(source, "'"); + let labels = complete_labels_at_opened(&backend, &uri, pos.line, pos.character).await; + assert_eq!(labels, ["reports"]); +} + +#[tokio::test] +async fn rate_limiters_ignore_unregistered_providers_and_homonyms() { + let source = r#" null); \RateLimiter::for('also-fake', fn () => null); } } +"#; + let (backend, _dir, uri) = workspace( + source, + homonym, + &[("src/Providers/UnusedProvider.php", PROVIDER)], + ) + .await; + assert_eq!( + slow_diagnostic_messages( + &backend, + uri.as_str(), + source, + "invalid_laravel_rate_limiter" + ), + ["Unknown rate limiter: 'fake'"] + ); +} + +#[tokio::test] +async fn rate_limiters_refresh_after_provider_buffer_edits() { + let source = " null); } }"; + let (backend, dir, uri) = workspace(source, provider, &[("src/Limits.php", helper)]).await; + let pos = position_after(source, "'"); + assert_eq!( + complete_labels_at_opened(&backend, &uri, pos.line, pos.character).await, + ["helper"] + ); + open_php_at( + &backend, + &dir, + "src/Limits.php", + &helper.replace("'helper'", "'edited'"), + ) + .await; + assert_eq!( + complete_labels_at_opened(&backend, &uri, pos.line, pos.character).await, + ["edited"] + ); + open_php_at( + &backend, + &dir, + "bootstrap/providers.php", + " null); } }"; + let unrelated = r" null); } }"; + let source = "middleware('throttle:');", "throttle:"), + ("new RateLimited('');", "'"), + ("new \\RateLimited('');", "'"), + ( + "new \\Illuminate\\Queue\\Middleware\\RateLimited(other: '');", + "'", + ), + ( + "new \\Illuminate\\Queue\\Middleware\\RateLimited(['']);", + "'", + ), + ( + "new \\Illuminate\\Queue\\Middleware\\RateLimited('ok', '');", + "'ok', '", + ), + ] { + let source = + format!(" null); } }"; + let source = " null); +\Illuminate\Support\Facades\RateLimiter::for('shared', fn () => null); +} }"; + let source = " (Backend, tempfile::TempDir) { - let dir = tempfile::tempdir().unwrap(); + let dir = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap(); let files = [ ("composer.json", COMPOSER), ("vendor/composer/installed.json", INSTALLED), From 522b9689188b25a989b0b32ba5729eefef9e2d14 Mon Sep 17 00:00:00 2001 From: Shuvro Roy Date: Mon, 9 Nov 2026 12:08:36 +0600 Subject: [PATCH 2/2] chore: preserve YAML indentation in constraint completion tests --- tests/integration/symfony_constraint_options.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/integration/symfony_constraint_options.rs b/tests/integration/symfony_constraint_options.rs index 9913e1fd4..617f8aed5 100644 --- a/tests/integration/symfony_constraint_options.rs +++ b/tests/integration/symfony_constraint_options.rs @@ -39,6 +39,7 @@ const LOOSE_PHP: &str = /// A constraint whose constructor collects any named argument. const OPEN_PHP: &str = "