diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index a688d3f58..7ddf90652 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -75,6 +75,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 #### Laravel +- **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. - **Container call parameters.** The parameter keys passed to `app()->call()` complete from the callable's parameters and go to them. Contributed by @shuvroroy. - **Laravel asset paths and Inertia pages.** The paths `asset()`, `mix()`, `@vite` and `Vite::asset()` name, and the pages `Inertia::render()`, `inertia()` and `Route::inertia()` render, complete from the files on disk, jump to the file, and are reported when nothing is there. An unknown Inertia page gets a quick fix that creates it. Page directories and extensions are read from `config/inertia.php`. - **Laravel related classes.** Code lenses on an Eloquent model go to its factory, policy, observers, migrations, API resources, and custom collection, and a factory, policy, or observer gets one back to its model. An event lists the listeners that handle it, whether registered or discovered from their `handle()` type hint, and a listener links to the events it handles. diff --git a/docs/todo.md b/docs/todo.md index 7d86b1cbd..4d973272b 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -180,7 +180,6 @@ ships, remove every row that names it. | L69 | [Benchmarks for Laravel string names and Blade](todo/laravel.md#l69-benchmarks-for-laravel-string-names-and-blade) | 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 | | BL20 | [Port Bladestan's tests](todo/blade.md#bl20-port-bladestans-tests) | Medium | Medium | | E7 | [Stub-based framework patches](todo/external-stubs.md#e7-stub-based-framework-patches) | Medium | Medium-High | | L71 | [Inertia page props and asset helpers on a typed URL generator](todo/laravel.md#l71-inertia-page-props-and-asset-helpers-on-a-typed-url-generator) | Low | Low-Medium | diff --git a/docs/todo/laravel.md b/docs/todo/laravel.md index 333aa9035..b90913ebd 100644 --- a/docs/todo/laravel.md +++ b/docs/todo/laravel.md @@ -473,19 +473,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 0c9a65a41..ea5e62dea 100644 --- a/examples/laravel/README.md +++ b/examples/laravel/README.md @@ -16,6 +16,7 @@ features against a real Laravel installation. - **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. - **Related classes.** Code lenses link a model to its factory, policy, observers, migrations, API resources, and custom collection, link a factory, policy, or observer back to its model, and link an event to its listeners and back. See `app/Models/BlogPost.php` and `app/Events/BlogPostPublished.php`. - **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 4b048d853..5ed5b7aa9 100644 --- a/examples/laravel/app/Demo.php +++ b/examples/laravel/app/Demo.php @@ -43,6 +43,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; @@ -1295,11 +1296,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 2c0f7ea04..88117d63c 100644 --- a/examples/laravel/assertions.php +++ b/examples/laravel/assertions.php @@ -2365,6 +2365,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 d35ddcad9..dcb635ffa 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>, @@ -82,6 +85,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<()>, @@ -100,6 +105,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 cb1def8d4..1d16ac7fd 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 4ec88ad10..dd70ea9bd 100644 --- a/src/completion/laravel_string_keys/mod.rs +++ b/src/completion/laravel_string_keys/mod.rs @@ -55,6 +55,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::DrupalEntityType @@ -207,6 +208,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::MixAsset => self.mix_manifest_keys(typed_prefix).into(), StringKeyKind::InertiaPage => self.inertia_pages().page_names().into(), @@ -352,7 +354,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| { @@ -363,13 +375,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 174381dd1..02f17cd1a 100644 --- a/src/completion/laravel_validation_strings.rs +++ b/src/completion/laravel_validation_strings.rs @@ -5,7 +5,6 @@ use tower_lsp_server::gen_lsp_types::*; use crate::Backend; use crate::completion::source::code_context::CodeContext; use crate::text_position::{offset_to_position, position_to_offset}; -use crate::text_scan::escape_literal_value; use crate::types::FileContext; use crate::virtual_members::laravel::validation_strings as validation; @@ -64,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. diff --git a/src/diagnostics/laravel_string_keys.rs b/src/diagnostics/laravel_string_keys.rs index d35a55de4..366fdc2da 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, @@ -81,6 +82,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, @@ -201,6 +203,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 service IDs a Drupal site's services files declare, sorted, and @@ -276,6 +280,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); @@ -522,6 +528,7 @@ impl<'a> DeclaredKeys<'a> { morph_map_enforced, gate_abilities, middleware_known, + rate_limiters, services, drupal_services, drupal_plugins, @@ -603,6 +610,16 @@ impl<'a> DeclaredKeys<'a> { self.judge_drupal_service(key) } 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::DrupalPlugin => self.judge_drupal_plugin(key), CheckedStringKind::ContainerParameter => self.judge_parameter(key), CheckedStringKind::SymfonyRoute => self.judge_symfony_route(key), diff --git a/src/hover/laravel.rs b/src/hover/laravel.rs index 516fc87ab..606b16614 100644 --- a/src/hover/laravel.rs +++ b/src/hover/laravel.rs @@ -272,6 +272,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 b0bc20007..2a84b410d 100644 --- a/src/parser/ast_publish.rs +++ b/src/parser/ast_publish.rs @@ -506,7 +506,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 8dd235463..86ecc19ad 100644 --- a/src/php_type/mod.rs +++ b/src/php_type/mod.rs @@ -35,6 +35,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 a258acab3..d30234c1e 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); emit_drupal_url_spans(inst.class, args, ctx); emit_drupal_route_permission_spans(inst.class, args, ctx); diff --git a/src/symbol_map/extraction/laravel/facade_calls.rs b/src/symbol_map/extraction/laravel/facade_calls.rs index ec189e87f..cabba332e 100644 --- a/src/symbol_map/extraction/laravel/facade_calls.rs +++ b/src/symbol_map/extraction/laravel/facade_calls.rs @@ -253,6 +253,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 cce748019..66bcc3dc7 100644 --- a/src/symbol_map/string_keys.rs +++ b/src/symbol_map/string_keys.rs @@ -65,6 +65,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`. @@ -324,6 +327,7 @@ impl StringKeyKind { | Self::GateAbility | Self::ContainerBinding | Self::Middleware + | Self::RateLimiter | Self::Env | Self::Asset | Self::MixAsset diff --git a/src/text_scan.rs b/src/text_scan.rs index 6527920b5..398ab2c20 100644 --- a/src/text_scan.rs +++ b/src/text_scan.rs @@ -169,6 +169,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 d3b533e1f..752fb0dee 100644 --- a/src/virtual_members/laravel/mod.rs +++ b/src/virtual_members/laravel/mod.rs @@ -146,6 +146,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::Asset @@ -339,6 +345,7 @@ pub(crate) fn find_laravel_string_key_references( | StringKeyKind::MorphAlias | StringKeyKind::GateAbility | StringKeyKind::Middleware + | StringKeyKind::RateLimiter | StringKeyKind::ContainerBinding | StringKeyKind::Env | StringKeyKind::Asset diff --git a/tests/integration/laravel_rate_limiters.rs b/tests/integration/laravel_rate_limiters.rs new file mode 100644 index 000000000..248b64a65 --- /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 = "