Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 0 additions & 1 deletion docs/todo.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
13 changes: 0 additions & 13 deletions docs/todo/laravel.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**
Expand Down
1 change: 1 addition & 0 deletions examples/laravel/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/`.
Expand Down
22 changes: 20 additions & 2 deletions examples/laravel/app/Demo.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions examples/laravel/app/Providers/DemoServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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/.
Expand Down Expand Up @@ -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.
Expand Down
27 changes: 27 additions & 0 deletions examples/laravel/assertions.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions src/backend/laravel/string_key_cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Arc<crate::virtual_members::laravel::rate_limiters::RateLimiterCatalog>>,
/// Installed validation rules and the project's custom registrations.
pub validation_rules:
Option<Arc<crate::virtual_members::laravel::validation_strings::catalog::RuleCatalog>>,
Expand Down Expand Up @@ -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<()>,
Expand All @@ -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()
Expand Down
26 changes: 23 additions & 3 deletions src/completion/laravel_string_keys/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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<StringKey<'a>> {
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();
Expand All @@ -1052,6 +1055,23 @@ fn instantiation_key<'a>(call: &StringCall<'a, '_>) -> Option<StringKey<'a>> {
}
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,
Expand Down
30 changes: 27 additions & 3 deletions src/completion/laravel_string_keys/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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<String> = candidates
.iter()
.filter(|name| {
Expand All @@ -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.
Expand Down
7 changes: 3 additions & 4 deletions src/completion/laravel_validation_strings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading