diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8cf6e2edf..7ba93afac 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -237,6 +237,7 @@ The symbol map also stores: - **Scope boundaries** (`scopes`): function, method, closure, and arrow function body ranges. Used by `find_enclosing_scope` to determine which scope the cursor is in. - **Template parameter definitions** (`template_defs`): `@template` tag locations so that template parameter names (e.g. `TKey`, `TModel`) that appear in docblock types can be resolved to their declaration site. - **Candidate render sites** (`view_receiver_sites`): the view names a method call spells when only the receiver's *type* decides whether it renders — a constructor-injected `Factory $views` behind `$this->views->make('page')`, a mailable held in a local. Extraction runs before the file's classes are resolved and cannot type the receiver, so it records the candidates and `blade/typed_receiver.rs` confirms them lazily through the shared type engine, once per file. Consumers of view keys (the call-site diagnostics, call-site inference, `lookup_symbol_map`, find-references) read the confirmed spans alongside the map's own `LaravelStringKey` spans. The reference candidate index takes the *unconfirmed* candidates, since a file has to be findable before it can be asked. +- **Config-backed Laravel resource names**: one declarative table maps direct helpers, facades, contextual attributes, and middleware parameters to their config subtrees. A `SymbolSpan` whose Laravel string kind is `ConfigResource(...)` stores the short source name, while completion, navigation, diagnostics, and references derive the full dot key only at the config boundary. This keeps source ranges and reference identity exact without duplicating one trigger table across LSP features. ### Tier 2: Stored Byte Offsets (cross-file jumps) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 1265a5eb3..68256f2fb 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **More configured Laravel service names are editor-aware.** Beyond storage disks, names passed through framework facades, contextual attributes, and route authentication middleware now complete from the matching config subtree; hover identifies the resource family, Ctrl+Click opens the exact declaration, references connect direct config access, and misspellings are reported with family-specific diagnostics. Channel arrays passed to `Log::stack()` are understood value by value. Contributed by @shuvroroy. - **Storage disk names are navigable wherever Laravel accepts one.** `Storage::disk()`, `fake()`, `persistentFake()`, `forgetDisk()`, and the `#[Storage]` container attribute now complete from `config/filesystems.php`; hover shows the config key, Ctrl+Click opens its declaration, and find-references links every use. Calls that require a configured disk report misspellings, while test fakes and disk eviction keep accepting the ad-hoc names Laravel permits at runtime. Contributed by @shuvroroy. - **Class and namespace moves from the command line.** `phpantom_lsp move FROM TO` moves one class or a whole namespace and updates declarations, imports, references, and PSR-4 paths across the project. Both sides can be fully-qualified names or Composer PSR-4 file/directory paths, and `--dry-run --format json` provides a validation-only form for scripts and coding agents. A destination that would overwrite an existing class or file is refused before any changes are made. A move into a namespace no PSR-4 mapping covers is called out rather than reported as a plain success, since the files cannot follow the declarations there and the autoloader stops finding them. A class installed by Composer is refused outright, the same way renaming one in the editor is. Contributed by @calebdw. - **Qualified names can be converted to imports in one action.** Invoke the refactoring on an absolute or relative qualified class, function, or constant to add the matching `use`, `use function`, or `use const` declaration and shorten every equivalent usage in the file. When the natural short name is already imported from elsewhere, the new import receives a namespace-derived alias instead. A companion action on the same cursor position does the whole namespace at once, importing every qualified class, function, and constant it contains and aliasing the ones whose short names collide. Contributed by @calebdw. diff --git a/docs/todo.md b/docs/todo.md index bfc0f82ae..15b5f772a 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -161,8 +161,11 @@ unlikely to move the needle for most users. | L24 | [Translation depth: JSON lang files, locales, placeholders](todo/laravel.md#l24-translation-depth-json-lang-files-locales-placeholders) | Medium-High | Medium-High | | L46 | [`->can()` on a user model the receiver does not name](todo/laravel.md#l46-can-on-a-user-model-the-receiver-does-not-name) | Medium-High | Medium-High | | L30 | [Eloquent attribute-array key completion](todo/laravel.md#l30-eloquent-attribute-array-key-completion) | 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 | +| L57 | [Laravel queue names](todo/laravel.md#l57-laravel-queue-names) | Low-Medium | Medium | +| L55 | [Typed controller middleware names](todo/laravel.md#l55-typed-controller-middleware-names) | Low-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 | -| L32 | [Config-backed named-resource strings](todo/laravel.md#l32-config-backed-named-resource-strings) (log channels, cache stores, guards, connections, rate limiters) | Medium | Medium | | L49 | [Unguarded Eloquent mass assignment diagnostic](todo/laravel.md#l49-unguarded-eloquent-mass-assignment-diagnostic) | Medium | Medium | | L17 | [Additional string contexts without booting](todo/laravel.md#l17-additional-string-contexts-without-booting) (middleware, assets, validation, Inertia) | Medium | Medium-High | | L54 | [Audit custom-builder and relation-closure inference against the PHPStan extensions](todo/laravel.md#l54-audit-custom-builder-and-relation-closure-inference-against-the-phpstan-extensions) | Medium | Medium-High | diff --git a/docs/todo/laravel.md b/docs/todo/laravel.md index 844e17817..6dd78d22a 100644 --- a/docs/todo/laravel.md +++ b/docs/todo/laravel.md @@ -663,7 +663,7 @@ accessors, `@property` tags) — no database needed. **Impact: Low-Medium · Complexity: Medium** -References and go-to-definition already work for the four indexed +References and go-to-definition already work for indexed Laravel string kinds, but the rename, document-highlight, and semantic-token arms are explicit no-ops. Wiring them up exceeds the Laravel LSP (which has none of the three): renaming a translation key updates the lang @@ -672,43 +672,49 @@ updates the `->name()` declaration and all usages; highlight and semantic tokens reuse the existing spans. Renaming a view name implies moving the Blade file — defer that one until the rest is in place. -#### L32. Config-backed named-resource strings +#### L56. Typed Laravel connection names **Impact: Medium · Complexity: Medium** -Storage disks are one instance of a general pattern: a method argument -names an entry under a known config subtree, and the config scanner -already parses those files. Auth guards (`auth('...')`, -`Auth::guard()`, `->middleware('auth:web')`), cache stores -(`Cache::store()`), log channels (`Log::channel()`), and storage disks -(`Storage::disk()`, test fakes, disk eviction, and `#[Storage]`) already -complete against their config subtree — but all of them route through -the generic `LaravelStringKind::Config` kind rather than a dedicated -one, so they get completion plus the shared config -diagnostics/go-to-definition and nothing family-specific (a "cache -store" hovers with the same generic wording as any other config key). -`Log::stack()` (array values) isn't recognized at all. Generalize into -a declarative table of `(trigger context, config path)` pairs so each -new family is one table row, and cover the rest of the family in one -pass: - -- **Database connections** — `DB::connection()`, `->connection()` / - `$connection` on models and jobs → `database.connections.*`. -- **Queue connections and queues** — `Queue::connection()`, - `->onConnection()` → `queue.connections.*`; `->onQueue()` names are - free-form (completion from literals seen elsewhere, no diagnostic). -- **Mailers** — `Mail::mailer()` → `mail.mailers.*`. -- **Broadcast connections** — `Broadcast::connection()` → - `broadcasting.connections.*`. -- **Rate limiter names** — not config-backed: registered via - `RateLimiter::for('name', …)` in providers. Scan literal - registrations (same shape as the macro scanner) and validate - `throttle:name` middleware parameters and `new RateLimited('name')` - against the set. - -Each family gets the full string-kind treatment for free once wired -as a `LaravelStringKey`: completion, go-to-definition (jump to the -config entry), hover, diagnostics, and references. +The method name `->connection()` does not identify one config subtree: the +receiver may select `database.connections.*`, `queue.connections.*`, or +`broadcasting.connections.*`. Resolve it through the shared type engine, and +treat `->onConnection()` as a queue connection. A model's `$connection` is a +database connection, while the same property on a queueable job selects a +queue connection. Each confirmed literal should receive the same completion, +hover, navigation, diagnostics, and references as the direct facade spelling. + +#### L55. Typed controller middleware names + +**Impact: Low-Medium · Complexity: Medium** + +`$this->middleware('auth:admin')` names middleware only when `$this` is a +Laravel controller; an unrelated class may define the same method for a +different purpose. Confirm the enclosing class through the shared type engine +before completing or validating embedded authentication guards. Static and +fluent `Route::middleware()` calls remain syntactically unambiguous. + +#### L57. Laravel queue names + +**Impact: Low-Medium · Complexity: Medium** + +`->onQueue()` names are free-form rather than config-backed. Complete from +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 diff --git a/examples/laravel/app/Demo.php b/examples/laravel/app/Demo.php index f9ef36783..51c2022a0 100644 --- a/examples/laravel/app/Demo.php +++ b/examples/laravel/app/Demo.php @@ -26,6 +26,12 @@ use Database\Factories\AnnotatedPostFactory; use Database\Factories\BlogAuthorFactory; use Database\Factories\EditorialFactory; +use Illuminate\Container\Attributes\Auth as InjectAuth; +use Illuminate\Container\Attributes\Authenticated as InjectAuthenticated; +use Illuminate\Container\Attributes\Cache as InjectCache; +use Illuminate\Container\Attributes\Database as InjectDatabase; +use Illuminate\Container\Attributes\Log as InjectLog; +use Illuminate\Container\Attributes\Storage as InjectStorage; use Illuminate\Contracts\Filesystem\Filesystem; use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Http\Client\Factory as HttpFactory; @@ -37,11 +43,16 @@ use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Broadcast; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Config; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Lang; +use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Mail; +use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Redirect; use Illuminate\Support\Facades\Redis; use Illuminate\Support\Facades\Response; @@ -887,6 +898,46 @@ public function laravelConfig(): void } + // ── Config-backed Laravel resource names ─────────────────────────── + + public function injectedNamedResources( + #[InjectAuth(guard: 'admin')] mixed $guard, + #[InjectAuthenticated(guard: 'admin')] mixed $user, + #[InjectCache(store: 'memory')] mixed $cache, + #[InjectLog(channel: 'daily')] mixed $logger, + #[InjectStorage(disk: 'pantry')] mixed $disk, + #[InjectDatabase(connection: 'mysql')] mixed $database, + ): void + { + // Contextual-attribute arguments complete and navigate against the + // same family-specific config entries as their facade counterparts. + } + + public function namedLaravelResources(): void + { + // Hover identifies each resource family, Ctrl+Click opens its config + // entry, and references include direct config() access to that entry. + auth('admin'); + Auth::guard('admin'); + Cache::store('memory'); + Log::channel('daily'); + Log::stack(['daily', 'stderr']); + Storage::disk('pantry'); + DB::connection('mysql'); + DB::connection('mysql::read'); + Queue::connection('redis'); + Mail::mailer('transactional'); + Broadcast::connection('internal'); + Route::middleware(['auth:admin']); + config('cache.stores.memory'); + + // Laravel supplies these null drivers at runtime even though no + // matching child needs to exist in cache.php or queue.php. + Cache::store('null'); + Queue::connection('null'); + } + + // ── Cache::remember() — closure return type binding ───────────────── public function cacheRemember(): void @@ -1157,21 +1208,20 @@ public function mixinModel(): void // ── Storage::fake() resolves to the concrete adapter ──────────────── public function storageFake( - #[\Illuminate\Container\Attributes\Storage('avatars')] Filesystem $avatars, + #[\Illuminate\Container\Attributes\Storage(disk: 'avatars')] Filesystem $avatars, ): void { // fake() declares the Filesystem contract but always builds a // FilesystemAdapter, so the adapter-only assertion helpers resolve. - // Disk names complete from config/filesystems.php, hover as their full - // config keys, and navigate back to their declarations — in the - // #[Storage] attribute above as much as in the calls below. - Storage::fake('avatars')->assertExists('me.png'); + // Disk names complete from config/filesystems.php, hover with their + // resource family, and navigate back to their declarations. + Storage::fake(disk: 'avatars')->assertExists('me.png'); Storage::persistentFake(disk: 'logs')->assertMissing('old.log'); // forgetDisk() takes one name or a list of them, and tolerates a disk // that was never configured, so an unknown name here is not flagged. Storage::forgetDisk('avatars'); - Storage::forgetDisk(['avatars', 'logs']); + Storage::forgetDisk(disk: ['avatars', 'logs']); } @@ -1183,7 +1233,7 @@ public function storageDisk(): void // disk config/filesystems.php configures ('local', 's3', ...) builds // a FilesystemAdapter, so adapter-only methods like download() // resolve on every configured disk, not just a faked one. - Storage::disk('s3')->download('report.pdf'); + Storage::disk(name: 's3')->download('report.pdf'); Storage::disk(name: 'local')->exists('notes.txt'); Storage::cloud()->assertExists('logo.png'); diff --git a/examples/laravel/assertions.php b/examples/laravel/assertions.php index 12f4f338a..96226df6d 100644 --- a/examples/laravel/assertions.php +++ b/examples/laravel/assertions.php @@ -6,7 +6,7 @@ * * These assertions verify that our assumptions about Laravel's runtime * behaviour are correct, so the LSP can model them accurately. - * Uses only reflection (no database or app boot required). + * Requires no external database or full application boot. */ require_once __DIR__ . '/vendor/autoload.php'; @@ -211,6 +211,81 @@ function assertMethodReturnType(string $class, string $method, string $expected) is_subclass_of(\App\Models\Administrator::class, \Illuminate\Contracts\Auth\Authenticatable::class) ); +// ─── Config-backed Laravel resource names ────────────────────────────────── + +$namedResourceConfigs = [ + 'auth.php' => ['guards', 'admin'], + 'cache.php' => ['stores', 'memory'], + 'logging.php' => ['channels', ['daily', 'stderr']], + 'filesystems.php' => ['disks', 'pantry'], + 'database.php' => ['connections', 'mysql'], + 'queue.php' => ['connections', 'redis'], + 'mail.php' => ['mailers', 'transactional'], + 'broadcasting.php' => ['connections', 'internal'], +]; +$previousContainer = \Illuminate\Container\Container::getInstance(); +$configContainer = new class extends \Illuminate\Container\Container { + public function storagePath(string $path = ''): string + { + return __DIR__ . '/storage' . ($path === '' ? '' : "/$path"); + } +}; +\Illuminate\Container\Container::setInstance($configContainer); +foreach ($namedResourceConfigs as $file => [$subtree, $names]) { + $config = require __DIR__ . "/config/$file"; + foreach ((array) $names as $name) { + check( + "$file declares $subtree.$name", + isset($config[$subtree]) && array_key_exists($name, $config[$subtree]) + ); + } +} +\Illuminate\Container\Container::setInstance($previousContainer); + +$databaseManager = (new ReflectionClass( + \Illuminate\Database\DatabaseManager::class +))->newInstanceWithoutConstructor(); +$parseConnectionName = new ReflectionMethod($databaseManager, 'parseConnectionName'); +foreach (['read', 'write', 'direct'] as $role) { + check( + "database role suffix ::$role selects the mysql config", + $parseConnectionName->invoke($databaseManager, "mysql::$role") === ['mysql', $role] + ); +} + +foreach ([ + \Illuminate\Cache\CacheManager::class, + \Illuminate\Queue\QueueManager::class, +] as $managerClass) { + $manager = (new ReflectionClass($managerClass))->newInstanceWithoutConstructor(); + $getConfig = new ReflectionMethod($manager, 'getConfig'); + check( + "$managerClass supplies the null driver without config", + $getConfig->invoke($manager, 'null') === ['driver' => 'null'] + ); +} + +$injectedResources = new ReflectionMethod(\App\Demo::class, 'injectedNamedResources'); +$attributeCases = [ + 'guard' => [\Illuminate\Container\Attributes\Auth::class, 'guard', 'admin'], + 'user' => [\Illuminate\Container\Attributes\Authenticated::class, 'guard', 'admin'], + 'cache' => [\Illuminate\Container\Attributes\Cache::class, 'store', 'memory'], + 'logger' => [\Illuminate\Container\Attributes\Log::class, 'channel', 'daily'], + 'disk' => [\Illuminate\Container\Attributes\Storage::class, 'disk', 'pantry'], + 'database' => [\Illuminate\Container\Attributes\Database::class, 'connection', 'mysql'], +]; +foreach ($injectedResources->getParameters() as $parameter) { + [$attributeClass, $property, $expected] = $attributeCases[$parameter->getName()]; + $attributes = $parameter->getAttributes($attributeClass); + check("{$parameter->getName()} has its contextual attribute", count($attributes) === 1); + if ($attributes !== []) { + check( + "{$parameter->getName()} contextual attribute selects $expected", + $attributes[0]->newInstance()->$property === $expected + ); + } +} + // ─── Paginator element types ───────────────────────────────────────────────── // paginate()/simplePaginate()/cursorPaginate() exist on the Eloquent Builder diff --git a/examples/laravel/config/broadcasting.php b/examples/laravel/config/broadcasting.php new file mode 100644 index 000000000..4ebcac64f --- /dev/null +++ b/examples/laravel/config/broadcasting.php @@ -0,0 +1,11 @@ + 'internal', + + 'connections' => [ + 'internal' => [ + 'driver' => 'log', + ], + ], +]; diff --git a/examples/laravel/config/cache.php b/examples/laravel/config/cache.php new file mode 100644 index 000000000..800e566ca --- /dev/null +++ b/examples/laravel/config/cache.php @@ -0,0 +1,11 @@ + 'memory', + + 'stores' => [ + 'memory' => [ + 'driver' => 'array', + ], + ], +]; diff --git a/examples/laravel/config/logging.php b/examples/laravel/config/logging.php new file mode 100644 index 000000000..c56bf225e --- /dev/null +++ b/examples/laravel/config/logging.php @@ -0,0 +1,17 @@ + 'daily', + + 'channels' => [ + 'daily' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/laravel.log'), + 'level' => 'debug', + ], + 'stderr' => [ + 'driver' => 'errorlog', + 'level' => 'debug', + ], + ], +]; diff --git a/examples/laravel/config/mail.php b/examples/laravel/config/mail.php new file mode 100644 index 000000000..03daaa4ee --- /dev/null +++ b/examples/laravel/config/mail.php @@ -0,0 +1,12 @@ + 'transactional', + + 'mailers' => [ + 'transactional' => [ + 'transport' => 'log', + 'channel' => 'daily', + ], + ], +]; diff --git a/examples/laravel/config/queue.php b/examples/laravel/config/queue.php new file mode 100644 index 000000000..be39ce8da --- /dev/null +++ b/examples/laravel/config/queue.php @@ -0,0 +1,13 @@ + 'redis', + + 'connections' => [ + 'redis' => [ + 'driver' => 'redis', + 'connection' => 'default', + 'queue' => 'default', + ], + ], +]; diff --git a/src/completion/handler/mod.rs b/src/completion/handler/mod.rs index 853fb6489..825735e4a 100644 --- a/src/completion/handler/mod.rs +++ b/src/completion/handler/mod.rs @@ -373,7 +373,7 @@ impl Backend { StringContext::InStringLiteral | StringContext::NotInString ) && let Some(response) = - self.try_laravel_string_key_completion(&content, position) + self.try_laravel_string_key_completion_in_file(&content, position, &ctx) { return Ok(Some(response)); } diff --git a/src/completion/laravel_string_keys.rs b/src/completion/laravel_string_keys.rs index 41c69b2a4..db76c2286 100644 --- a/src/completion/laravel_string_keys.rs +++ b/src/completion/laravel_string_keys.rs @@ -19,15 +19,15 @@ use std::collections::HashMap; use tower_lsp::lsp_types::*; use crate::Backend; -use crate::symbol_map::LaravelStringKind; +use crate::symbol_map::{LaravelConfigResource, LaravelStringKind}; use crate::text_position::position_to_offset; -use crate::virtual_members::laravel::{is_storage_facade_name, storage_facade_local_names}; +use crate::types::FileContext; // ─── Context ──────────────────────────────────────────────────────────────── -struct LaravelStringKeyContext { +struct LaravelStringKeyContext<'a> { kind: LaravelStringKind, - prefix: String, + prefix: &'a str, /// Byte offset of the string content start (right after the opening quote). content_start_offset: usize, /// When set, the key is a sub-key under this config path prefix. @@ -58,6 +58,15 @@ fn is_unescaped(bytes: &[u8], index: usize) -> bool { (index - before).is_multiple_of(2) } +#[derive(Clone, Copy, PartialEq, Eq)] +enum PhpLexState { + Code, + SingleQuoted, + DoubleQuoted, + LineComment, + BlockComment, +} + /// Find the unmatched call parenthesis enclosing a named argument. fn enclosing_call_open_paren(content: &str) -> Option { let bytes = content.as_bytes(); @@ -177,16 +186,47 @@ fn string_literal_is_array_key(content: &str, cursor: usize, quote: u8) -> bool let mut index = cursor; while index < bytes.len() { if bytes[index] == quote && is_unescaped(bytes, index) { - return content[index + 1..].trim_start().starts_with("=>"); - } - if bytes[index] == b'\n' { - return false; + let after_literal = skip_php_trivia_forward(content, index + 1); + return content[after_literal..].starts_with("=>"); } index += 1; } false } +/// Skip whitespace and PHP comments without allocating or scanning beyond the +/// first real token. Comments are valid between an array key and its `=>`. +fn skip_php_trivia_forward(content: &str, mut index: usize) -> usize { + let bytes = content.as_bytes(); + loop { + while bytes.get(index).is_some_and(u8::is_ascii_whitespace) { + index += 1; + } + match bytes.get(index..index.saturating_add(2)) { + Some(b"//") => { + index += 2; + while bytes.get(index).is_some_and(|byte| *byte != b'\n') { + index += 1; + } + } + Some(b"/*") => { + index += 2; + while index < bytes.len() && bytes.get(index..index + 2) != Some(b"*/") { + index += 1; + } + index = (index + 2).min(bytes.len()); + } + _ if bytes.get(index) == Some(&b'#') => { + index += 1; + while bytes.get(index).is_some_and(|byte| *byte != b'\n') { + index += 1; + } + } + _ => return index, + } + } +} + fn string_argument_context<'a>( content: &'a str, before_quote: &'a str, @@ -212,14 +252,651 @@ fn string_argument_context<'a>( }) } +fn imported_item_target( + item: &str, + group_prefix: Option<&str>, + expected_namespace: &str, + candidates: &[&'static str], + referenced_name: &str, +) -> Option> { + let mut words = item.split_whitespace(); + let imported_name = words.next()?; + let alias = match words.next() { + Some(as_keyword) if as_keyword.eq_ignore_ascii_case("as") => Some(words.next()?), + Some(_) => return None, + None => None, + }; + if words.next().is_some() { + return None; + } + + let imported_name = imported_name.trim_start_matches('\\'); + let local_name = + alias.unwrap_or_else(|| imported_name.rsplit('\\').next().unwrap_or(imported_name)); + if !local_name.eq_ignore_ascii_case(referenced_name) { + return None; + } + + let target = if let Some(prefix) = group_prefix { + prefix + .trim_start_matches('\\') + .trim_end_matches('\\') + .eq_ignore_ascii_case(expected_namespace) + .then(|| { + candidates + .iter() + .copied() + .find(|candidate| imported_name.eq_ignore_ascii_case(candidate)) + }) + .flatten() + } else { + let (namespace, short) = imported_name + .rsplit_once('\\') + .unwrap_or(("", imported_name)); + namespace + .eq_ignore_ascii_case(expected_namespace) + .then(|| { + candidates + .iter() + .copied() + .find(|candidate| short.eq_ignore_ascii_case(candidate)) + }) + .flatten() + }; + Some(target) +} + +/// Resolve one spelling against a small, fixed set of framework classes. +/// +/// `Some` identifies the matched short name. `None` covers both an unknown +/// spelling and an explicit import of an unrelated class under the same local +/// name, which is important for rejecting namespace-local facade homonyms. +#[derive(Clone, Copy)] +struct ResolvedClassReference<'a> { + written: &'a str, + semantic: &'a str, + semantic_is_authoritative: bool, +} + +fn resolve_known_class_reference( + content: &str, + reference: ResolvedClassReference<'_>, + expected_namespace: &str, + candidates: &[&'static str], + allow_root_alias: bool, + indexed_class_exists: Option<&dyn Fn(&str) -> bool>, +) -> Option<&'static str> { + let is_root_qualified = reference.written.starts_with('\\'); + let class_name = reference.semantic.trim_start_matches('\\'); + if class_name.contains('\\') { + let (namespace, short) = class_name.rsplit_once('\\')?; + return namespace + .eq_ignore_ascii_case(expected_namespace) + .then(|| { + candidates + .iter() + .copied() + .find(|candidate| short.eq_ignore_ascii_case(candidate)) + }) + .flatten(); + } + + if reference.semantic_is_authoritative { + if !allow_root_alias { + return None; + } + let candidate = candidates + .iter() + .copied() + .find(|candidate| class_name.eq_ignore_ascii_case(candidate))?; + return (!indexed_class_exists.is_some_and(|exists| exists(candidate))) + .then_some(candidate); + } + + if !is_root_qualified { + for statement in content.split(';') { + let mut line_offset = 0usize; + let mut clause = None; + for line in statement.split_inclusive('\n') { + let trimmed = line.trim_start(); + if trimmed + .get(..4) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case("use ")) + { + let leading = line.len() - trimmed.len(); + clause = Some(statement[line_offset + leading + 4..].trim()); + break; + } + line_offset += line.len(); + } + let Some(clause) = clause else { + continue; + }; + + if let Some(open) = clause.find('{') { + let Some(close) = clause.rfind('}') else { + continue; + }; + let prefix = clause[..open].trim(); + if let Some(target) = clause[open + 1..close].split(',').find_map(|item| { + imported_item_target( + item, + Some(prefix), + expected_namespace, + candidates, + class_name, + ) + }) { + return target; + } + } else if let Some(target) = clause.split(',').find_map(|item| { + imported_item_target(item, None, expected_namespace, candidates, class_name) + }) { + return target; + } + } + } + + if !allow_root_alias + || (!is_root_qualified && crate::text_scan::source_declares_namespace(content)) + { + return None; + } + let candidate = candidates + .iter() + .copied() + .find(|candidate| class_name.eq_ignore_ascii_case(candidate))?; + (!indexed_class_exists.is_some_and(|exists| exists(candidate))).then_some(candidate) +} + +fn config_resource_static_trigger( + content: &str, + reference: ResolvedClassReference<'_>, + method: &str, + indexed_class_exists: Option<&dyn Fn(&str) -> bool>, +) -> Option { + if !crate::symbol_map::laravel_resources::static_method_may_trigger(method) { + return None; + } + + let facade = resolve_known_class_reference( + content, + reference, + "Illuminate\\Support\\Facades", + crate::symbol_map::laravel_resources::RESOURCE_FACADES, + true, + indexed_class_exists, + )?; + crate::symbol_map::laravel_resources::static_method_trigger(facade, method) +} + +fn config_resource_attribute_trigger( + content: &str, + reference: ResolvedClassReference<'_>, + indexed_class_exists: Option<&dyn Fn(&str) -> bool>, +) -> Option { + let attribute = resolve_known_class_reference( + content, + reference, + "Illuminate\\Container\\Attributes", + crate::symbol_map::laravel_resources::RESOURCE_ATTRIBUTES, + false, + indexed_class_exists, + )?; + crate::symbol_map::laravel_resources::attribute_trigger(attribute) +} + +fn is_laravel_facade_reference( + content: &str, + reference: ResolvedClassReference<'_>, + facade: &'static str, + indexed_class_exists: Option<&dyn Fn(&str) -> bool>, +) -> bool { + resolve_known_class_reference( + content, + reference, + "Illuminate\\Support\\Facades", + &[facade], + true, + indexed_class_exists, + ) + .is_some() +} + +#[inline] +fn semantic_class_reference<'a>( + written: &'a str, + offset: usize, + resolved_names: Option<&'a crate::names::OwnedResolvedNames>, +) -> ResolvedClassReference<'a> { + match resolved_names.and_then(|names| names.get(offset as u32)) { + Some(name) => ResolvedClassReference { + written, + semantic: name, + semantic_is_authoritative: true, + }, + None => ResolvedClassReference { + written, + semantic: written, + semantic_is_authoritative: false, + }, + } +} + +/// Find the last syntactic PHP attribute opener before `end`. +/// Attribute-looking text inside strings and comments is deliberately ignored. +fn last_attribute_open_before(content: &str, end: usize) -> Option { + let bytes = content.as_bytes(); + let end = end.min(bytes.len()); + let mut state = PhpLexState::Code; + let mut last = None; + let mut index = 0usize; + + while index < end { + let byte = bytes[index]; + match state { + PhpLexState::Code => match byte { + b'\'' => { + state = PhpLexState::SingleQuoted; + index += 1; + } + b'"' => { + state = PhpLexState::DoubleQuoted; + index += 1; + } + b'/' if bytes.get(index + 1) == Some(&b'/') => { + state = PhpLexState::LineComment; + index += 2; + } + b'/' if bytes.get(index + 1) == Some(&b'*') => { + state = PhpLexState::BlockComment; + index += 2; + } + b'#' if bytes.get(index + 1) == Some(&b'[') => { + last = Some(index); + index += 2; + } + b'#' => { + state = PhpLexState::LineComment; + index += 1; + } + _ => index += 1, + }, + PhpLexState::SingleQuoted => { + if byte == b'\'' && is_unescaped(bytes, index) { + state = PhpLexState::Code; + } + index += 1; + } + PhpLexState::DoubleQuoted => { + if byte == b'"' && is_unescaped(bytes, index) { + state = PhpLexState::Code; + } + index += 1; + } + PhpLexState::LineComment => { + if byte == b'\n' || byte == b'\r' { + state = PhpLexState::Code; + } + index += 1; + } + PhpLexState::BlockComment => { + if byte == b'*' && bytes.get(index + 1) == Some(&b'/') { + state = PhpLexState::Code; + index += 2; + } else { + index += 1; + } + } + } + } + + last +} + +/// Start of the class name for the attribute call ending at `name_start`. +/// +/// Attribute groups may contain earlier attributes and arbitrary balanced +/// argument expressions. Only a class that starts a top-level group element +/// is accepted; a lookalike call nested inside another attribute is not. +fn attribute_class_start(before_paren: &str, name_start: usize) -> Option { + let bytes = before_paren.as_bytes(); + let mut class_start = name_start; + while class_start > 0 + && (bytes[class_start - 1].is_ascii_alphanumeric() + || matches!(bytes[class_start - 1], b'_' | b'\\')) + { + class_start -= 1; + } + + if !matches!( + before_paren[..class_start].trim_end().as_bytes().last(), + Some(b'[' | b',') + ) { + return None; + } + + let open = last_attribute_open_before(before_paren, class_start)?; + let between = &before_paren[open + 2..class_start]; + let bytes = between.as_bytes(); + let mut round = 0usize; + let mut square = 0usize; + let mut curly = 0usize; + let mut quote = None; + let mut element_start = 0usize; + + for (index, byte) in bytes.iter().copied().enumerate() { + if let Some(active) = quote { + if byte == active && is_unescaped(bytes, index) { + quote = None; + } + continue; + } + match byte { + b'\'' | b'"' => quote = Some(byte), + b'(' => round += 1, + b')' if round > 0 => round -= 1, + b'[' => square += 1, + b']' if square > 0 => square -= 1, + b']' => return None, + b'{' => curly += 1, + b'}' if curly > 0 => curly -= 1, + b',' if round == 0 && square == 0 && curly == 0 => element_start = index + 1, + _ => {} + } + } + + (quote.is_none() + && round == 0 + && square == 0 + && curly == 0 + && between[element_start..].trim().is_empty()) + .then_some(class_start) +} + +/// Find the top-level statement boundary before a fluent receiver chain. +/// Newlines are ordinary PHP whitespace, while semicolons and braces inside +/// balanced calls/closures belong to the receiver expression itself. +fn receiver_chain_start(prefix: &str) -> usize { + let bytes = prefix.as_bytes(); + let mut boundary = 0usize; + let mut round = 0usize; + let mut square = 0usize; + let mut state = PhpLexState::Code; + let mut index = 0usize; + + while index < bytes.len() { + let byte = bytes[index]; + match state { + PhpLexState::Code => { + if bytes + .get(index..index.saturating_add(5)) + .is_some_and(|tag| tag.eq_ignore_ascii_case(b"") { + boundary = index + 2; + index += 2; + continue; + } + match byte { + b'\'' => state = PhpLexState::SingleQuoted, + b'"' => state = PhpLexState::DoubleQuoted, + b'/' if bytes.get(index + 1) == Some(&b'/') => { + state = PhpLexState::LineComment; + index += 1; + } + b'/' if bytes.get(index + 1) == Some(&b'*') => { + state = PhpLexState::BlockComment; + index += 1; + } + b'#' if bytes.get(index + 1) != Some(&b'[') => { + state = PhpLexState::LineComment; + } + b'(' => round += 1, + b')' if round > 0 => round -= 1, + b'[' => square += 1, + b']' if square > 0 => square -= 1, + b';' | b'{' | b'}' if round == 0 && square == 0 => boundary = index + 1, + _ => {} + } + } + PhpLexState::SingleQuoted => { + if byte == b'\'' && is_unescaped(bytes, index) { + state = PhpLexState::Code; + } + } + PhpLexState::DoubleQuoted => { + if byte == b'"' && is_unescaped(bytes, index) { + state = PhpLexState::Code; + } + } + PhpLexState::LineComment => { + if byte == b'\n' || byte == b'\r' { + state = PhpLexState::Code; + } + } + PhpLexState::BlockComment => { + if byte == b'*' && bytes.get(index + 1) == Some(&b'/') { + state = PhpLexState::Code; + index += 1; + } + } + } + index += 1; + } + + boundary +} + +fn middleware_completion_context(prefix: &str) -> Option<(LaravelStringKind, &str, usize)> { + let colon = prefix.find(':')?; + let alias = &prefix[..=colon]; + if alias != "auth:" { + return None; + } + let resource = crate::symbol_map::laravel_resources::middleware_resource(alias)?; + + let payload = &prefix[colon + 1..]; + let raw_current = payload + .rsplit_once(',') + .map_or(payload, |(_, current)| current); + let current = raw_current.trim_start(); + let start = prefix.len().saturating_sub(raw_current.len()); + Some((LaravelStringKind::ConfigResource(resource), current, start)) +} + +fn is_gate_check_method(method: &str) -> bool { + match method.len() { + 3 => method.eq_ignore_ascii_case("any") || method.eq_ignore_ascii_case("has"), + 4 => method.eq_ignore_ascii_case("none"), + 5 => method.eq_ignore_ascii_case("check"), + 6 => method.eq_ignore_ascii_case("allows") || method.eq_ignore_ascii_case("denies"), + 7 => method.eq_ignore_ascii_case("inspect"), + _ => false, + } +} + +fn chain_starts_at_laravel_facade( + content: &str, + chain: &str, + chain_offset: usize, + resolved_names: Option<&crate::names::OwnedResolvedNames>, + facade: &'static str, + indexed_class_exists: Option<&dyn Fn(&str) -> bool>, +) -> bool { + let bytes = chain.as_bytes(); + let mut class_start = 0usize; + while class_start < bytes.len() && bytes[class_start].is_ascii_whitespace() { + class_start += 1; + } + let mut class_end = class_start; + while class_end < bytes.len() + && (bytes[class_end].is_ascii_alphanumeric() || matches!(bytes[class_end], b'_' | b'\\')) + { + class_end += 1; + } + if class_start == class_end { + return false; + } + let mut colons = class_end; + while colons < bytes.len() && bytes[colons].is_ascii_whitespace() { + colons += 1; + } + if bytes.get(colons..colons + 2) != Some(b"::") { + return false; + } + + let written = &chain[class_start..class_end]; + let reference = semantic_class_reference(written, chain_offset + class_start, resolved_names); + is_laravel_facade_reference(content, reference, facade, indexed_class_exists) + && is_method_chain_suffix(&chain[colons + 2..]) +} + +/// Whether everything after a top-level `Facade::` token remains on the same +/// receiver spine. Nested arguments may contain arbitrary PHP; at top level +/// only identifiers, calls, and static/instance chain operators are valid. +fn is_method_chain_suffix(suffix: &str) -> bool { + let bytes = suffix.as_bytes(); + let mut round = 0usize; + let mut square = 0usize; + let mut curly = 0usize; + let mut state = PhpLexState::Code; + let mut instance_links = 0usize; + let mut index = 0usize; + while index < bytes.len() { + let byte = bytes[index]; + match state { + PhpLexState::SingleQuoted => { + if byte == b'\'' && is_unescaped(bytes, index) { + state = PhpLexState::Code; + } + index += 1; + continue; + } + PhpLexState::DoubleQuoted => { + if byte == b'"' && is_unescaped(bytes, index) { + state = PhpLexState::Code; + } + index += 1; + continue; + } + PhpLexState::LineComment => { + if byte == b'\n' || byte == b'\r' { + state = PhpLexState::Code; + } + index += 1; + continue; + } + PhpLexState::BlockComment => { + if byte == b'*' && bytes.get(index + 1) == Some(&b'/') { + state = PhpLexState::Code; + index += 2; + } else { + index += 1; + } + continue; + } + PhpLexState::Code => {} + } + if byte == b'/' && bytes.get(index + 1) == Some(&b'/') { + state = PhpLexState::LineComment; + index += 2; + continue; + } + if byte == b'/' && bytes.get(index + 1) == Some(&b'*') { + state = PhpLexState::BlockComment; + index += 2; + continue; + } + if byte == b'#' && bytes.get(index + 1) != Some(&b'[') { + state = PhpLexState::LineComment; + index += 1; + continue; + } + if round > 0 || square > 0 || curly > 0 { + match byte { + b'\'' => state = PhpLexState::SingleQuoted, + b'"' => state = PhpLexState::DoubleQuoted, + b'(' => round += 1, + b')' if round > 0 => round -= 1, + b'[' => square += 1, + b']' if square > 0 => square -= 1, + b'{' => curly += 1, + b'}' if curly > 0 => curly -= 1, + _ => {} + } + index += 1; + continue; + } + + match byte { + b if b.is_ascii_alphanumeric() || matches!(b, b'_' | b' ' | b'\t' | b'\r' | b'\n') => { + index += 1; + } + b'(' => { + round = 1; + index += 1; + } + b'[' => { + square = 1; + index += 1; + } + b'{' => { + curly = 1; + index += 1; + } + b':' if bytes.get(index + 1) == Some(&b':') => index += 2, + b'-' if bytes.get(index + 1) == Some(&b'>') => { + instance_links += 1; + if instance_links > crate::symbol_map::laravel_resources::FACADE_CHAIN_DEPTH { + return false; + } + index += 2; + } + b'?' if bytes.get(index + 1) == Some(&b'-') && bytes.get(index + 2) == Some(&b'>') => { + instance_links += 1; + if instance_links > crate::symbol_map::laravel_resources::FACADE_CHAIN_DEPTH { + return false; + } + index += 3; + } + _ => return false, + } + } + matches!(state, PhpLexState::Code | PhpLexState::LineComment) + && round == 0 + && square == 0 + && curly == 0 +} + // ─── Detection ────────────────────────────────────────────────────────────── /// Detect if the cursor is inside a supported string argument of a Laravel /// helper or facade call. Returns the key kind and the prefix typed so far. +#[cfg(test)] fn detect_laravel_string_key_context( content: &str, position: Position, -) -> Option { +) -> Option> { + detect_laravel_string_key_context_inner(content, position, None, None, None) +} + +fn detect_laravel_string_key_context_inner<'a>( + content: &'a str, + position: Position, + resolved_names: Option<&'a crate::names::OwnedResolvedNames>, + indexed_function_exists: Option<&dyn Fn(&str) -> bool>, + indexed_class_exists: Option<&dyn Fn(&str) -> bool>, +) -> Option> { let cursor_offset = position_to_offset(content, position) as usize; let bytes = content.as_bytes(); @@ -242,7 +919,8 @@ fn detect_laravel_string_key_context( } } let quote_pos = quote_pos?; - let prefix = content[quote_pos + 1..cursor_offset].to_string(); + let mut prefix = &content[quote_pos + 1..cursor_offset]; + let mut content_start_offset = quote_pos + 1; // ── Locate the call argument that owns this string ───────────── let before_quote = content[..quote_pos].trim_end(); @@ -271,110 +949,57 @@ fn detect_laravel_string_key_context( let trimmed_before = before_name.trim_end(); let is_instance_method = trimmed_before.ends_with("->") || trimmed_before.ends_with("?->"); - // Check for PHP attribute syntax: #[Config('key')] or - // #[\Illuminate\Container\Attributes\Config('key')]. - // Everything between the nearest `#[` and this final class-name segment - // must itself be a class-name prefix. This recognizes FQN attributes - // without letting an unrelated attribute earlier in the file match. - let is_attribute = trimmed_before.rfind("#[").is_some_and(|start| { - trimmed_before[start + 2..] - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'\\') - }); - - // ── Map container attributes to config sub-prefixes ──────────── - let (kind, config_sub_prefix) = if is_attribute { + // Check for PHP attribute syntax: #[Config('key')], grouped attributes, + // and fully-qualified container attributes. + let current_attribute_class_start = (!is_static && !is_instance_method) + .then(|| attribute_class_start(before_paren, name_start)) + .flatten(); + let is_attribute = current_attribute_class_start.is_some(); + + let kind = if is_attribute { if argument.shape != StringArgumentShape::Scalar { return None; } - // Resolve the attribute to its Laravel FQN. When the name is - // fully qualified (contains `\`), match the FQN directly. - // When it's a short name, verify the file imports it from - // `Illuminate\Container\Attributes\`. - const ATTR_NS: &str = "Illuminate\\Container\\Attributes\\"; - - // Reconstruct the full attribute class name by scanning backwards - // past namespace separators. `func_name` only captured the last - // segment (e.g. `Config`), but the FQN parts (if any) are in - // `before_name` (e.g. `#[\Illuminate\Container\Attributes\`). - let full_attr_name = { - let bn = before_name.trim_end().trim_end_matches('\\'); - // Check for `#[` or `#[\` prefix — extract everything after `#[` - if let Some(idx) = bn.rfind("#[") { - let after_hash = &bn[idx + 2..].trim_start_matches('\\'); - if after_hash.is_empty() { - func_name.to_string() - } else { - format!("{}\\{}", after_hash, func_name) - } - } else { - func_name.to_string() - } - }; - let attr_class = full_attr_name.trim_start_matches('\\'); - let short = attr_class.rsplit('\\').next().unwrap_or(attr_class); - - // The namespace holding `#[RedirectToRoute]`, which names a route - // rather than a config key. - const HTTP_ATTR_NS: &str = "Illuminate\\Foundation\\Http\\Attributes\\"; - - let is_fqn = attr_class.contains('\\'); - let attr_matches = |ns: &str, expected_short: &str| -> bool { - if is_fqn { - attr_class == format!("{}{}", ns, expected_short) - } else if short == expected_short { - // Verify the import exists in the file. - content.contains(&format!("use {}{};", ns, expected_short)) - || content.contains(&format!("use {}{{", ns)) - } else { - false - } - }; - let fqn_matches = |expected_short: &str| attr_matches(ATTR_NS, expected_short); - // `#[Storage]` turns any argument into a `filesystems.disks.*` key, - // so an application's own same-named attribute would invent one. - // The short spelling therefore has to be imported by that exact - // name, matching what the symbol map records. - let storage_attr_matches = || { - if is_fqn { - attr_class == format!("{ATTR_NS}Storage") - } else { - short == "Storage" - && crate::text_scan::imports_class_as( - content, - &format!("{ATTR_NS}Storage"), - "Storage", - ) - } - }; - - // `#[Storage(disk: '…')]` is the one container attribute whose - // argument is recognised by name. - if let Some(name) = argument.named_argument - && !(storage_attr_matches() && name.eq_ignore_ascii_case("disk")) + let attr_start = current_attribute_class_start?; + let written_class = &before_paren[attr_start..]; + let reference = semantic_class_reference(written_class, attr_start, resolved_names); + if resolve_known_class_reference( + content, + reference, + "Illuminate\\Container\\Attributes", + &["Config"], + false, + indexed_class_exists, + ) + .is_some() { - return None; - } - - if fqn_matches("Config") { - (Some(LaravelStringKind::Config), None) - } else if fqn_matches("Database") || fqn_matches("DB") { - ( - Some(LaravelStringKind::Config), - Some("database.connections."), - ) - } else if fqn_matches("Cache") { - (Some(LaravelStringKind::Config), Some("cache.stores.")) - } else if fqn_matches("Log") { - (Some(LaravelStringKind::Config), Some("logging.channels.")) - } else if storage_attr_matches() { - (Some(LaravelStringKind::Config), Some("filesystems.disks.")) - } else if fqn_matches("Auth") || fqn_matches("Authenticated") { - (Some(LaravelStringKind::Config), Some("auth.guards.")) - } else if attr_matches(HTTP_ATTR_NS, "RedirectToRoute") { - (Some(LaravelStringKind::Route), None) + argument + .named_argument + .is_none_or(|name| name == "key") + .then_some(LaravelStringKind::Config) + } else if let Some(trigger) = + config_resource_attribute_trigger(content, reference, indexed_class_exists) + { + argument + .named_argument + .is_none_or(|name| name == trigger.argument) + .then_some(LaravelStringKind::ConfigResource(trigger.kind)) + } else if resolve_known_class_reference( + content, + reference, + "Illuminate\\Foundation\\Http\\Attributes", + &["RedirectToRoute"], + false, + indexed_class_exists, + ) + .is_some() + { + argument + .named_argument + .is_none() + .then_some(LaravelStringKind::Route) } else { - (None, None) + None } } else if is_static { let before_colons = &trimmed_before[..trimmed_before.len() - 2].trim_end(); @@ -387,114 +1012,107 @@ fn detect_laravel_string_key_context( { cls_start -= 1; } - let class_name = &before_colons[cls_start..]; - let short = class_name.rsplit('\\').next().unwrap_or(class_name); + let written_class = &before_colons[cls_start..]; + let reference = semantic_class_reference(written_class, cls_start, resolved_names); + // Preserve the pre-existing legacy facade behavior. New resource + // triggers above resolve semantic aliases exactly; feeding an + // unrelated `Vendor\Config as Foo` target into the legacy short-name + // table would otherwise misclassify `Foo::get()` as Laravel Config. + let short = written_class.rsplit('\\').next().unwrap_or(written_class); let fn_lower = func_name.to_ascii_lowercase(); let short_lower = short.to_ascii_lowercase(); - - // The `Storage` facade's disk-name arguments: the parameter the disk - // goes in, and whether that parameter also accepts a list. The - // facade is only resolved through the file's imports once a method - // name has matched — `disk()`, `fake()` and `forgetDisk()` are common - // names on unrelated facades, and resolving scans the whole buffer. - let storage_argument = match fn_lower.as_str() { - "disk" => Some(("name", false)), - "fake" | "persistentfake" => Some(("disk", false)), - "forgetdisk" => Some(("disk", true)), - _ => None, - } - .filter(|_| is_storage_facade_name(class_name, &storage_facade_local_names(content))); - let accepts_array = matches!( + let legacy_accepts_array = matches!( (short_lower.as_str(), fn_lower.as_str()), ("config", "getmany") | ("route", "is" | "currentroutenamed") ); - if let Some((expected_name, accepts_array)) = storage_argument { + if let Some(trigger) = + config_resource_static_trigger(content, reference, func_name, indexed_class_exists) + { if argument .named_argument - .is_some_and(|name| !name.eq_ignore_ascii_case(expected_name)) - || (argument.shape == StringArgumentShape::ArrayValue && !accepts_array) + .is_some_and(|name| name != trigger.argument) + || (argument.shape == StringArgumentShape::ArrayValue + && !trigger.shape.accepts_array()) + || (argument.shape == StringArgumentShape::Scalar + && !trigger.shape.accepts_scalar()) { return None; } - (Some(LaravelStringKind::Config), Some("filesystems.disks.")) + Some(LaravelStringKind::ConfigResource(trigger.kind)) + } else if func_name.eq_ignore_ascii_case("middleware") + && argument + .named_argument + .is_none_or(|name| name == "middleware") + && is_laravel_facade_reference(content, reference, "Route", indexed_class_exists) + { + let (middleware_kind, middleware_prefix, relative_start) = + middleware_completion_context(prefix)?; + prefix = middleware_prefix; + content_start_offset += relative_start; + Some(middleware_kind) } else if argument.named_argument.is_some() || (argument.shape != StringArgumentShape::Scalar - && (!accepts_array || !before_quote.trim_end().ends_with('['))) + && (!legacy_accepts_array || !before_quote.trim_end().ends_with('['))) { - (None, None) + None } else { match (short_lower.as_str(), fn_lower.as_str()) { ( "config", "get" | "getmany" | "set" | "has" | "boolean" | "array" | "collection" | "prepend" | "push", - ) => (Some(LaravelStringKind::Config), None), - ("view", "make" | "exists") => (Some(LaravelStringKind::View), None), + ) => Some(LaravelStringKind::Config), + ("view", "make" | "exists") => Some(LaravelStringKind::View), ("lang", "get" | "has" | "hasforlocale" | "choice") => { - (Some(LaravelStringKind::Trans), None) + Some(LaravelStringKind::Trans) } // Route names reached through the URL-building facades, and the // "is the current route named …?" predicates. ( "url" | "redirect" | "response", "route" | "signedroute" | "temporarysignedroute" | "redirecttoroute", - ) => (Some(LaravelStringKind::Route), None), - ("route", "is" | "currentroutenamed") => (Some(LaravelStringKind::Route), None), - ("env", "get" | "getorfail") => (Some(LaravelStringKind::Env), None), - // Facade methods that accept config sub-keys: - ("auth", "guard") => (Some(LaravelStringKind::Config), Some("auth.guards.")), - ("db", "connection") => ( - Some(LaravelStringKind::Config), - Some("database.connections."), - ), - ("cache", "store") => (Some(LaravelStringKind::Config), Some("cache.stores.")), - ("log", "channel") => (Some(LaravelStringKind::Config), Some("logging.channels.")), + ) => Some(LaravelStringKind::Route), + ("route", "is" | "currentroutenamed") => Some(LaravelStringKind::Route), + ("env", "get" | "getorfail") => Some(LaravelStringKind::Env), // Artisan command names. - ("artisan", "call" | "queue") => (Some(LaravelStringKind::Command), None), - ("schedule", "command") => (Some(LaravelStringKind::Command), None), + ("artisan", "call" | "queue") => Some(LaravelStringKind::Command), + ("schedule", "command") => Some(LaravelStringKind::Command), // Eloquent morph aliases. - ("relation", "getmorphedmodel") => (Some(LaravelStringKind::MorphAlias), None), - ("model", "getactualclassnameformorph") => { - (Some(LaravelStringKind::MorphAlias), None) - } + ("relation", "getmorphedmodel") => Some(LaravelStringKind::MorphAlias), + ("model", "getactualclassnameformorph") => Some(LaravelStringKind::MorphAlias), // Authorization abilities checked through the Gate facade. ( "gate", "allows" | "denies" | "check" | "any" | "none" | "authorize" | "inspect" | "has" | "define", - ) => (Some(LaravelStringKind::GateAbility), None), - _ => (None, None), + ) => Some(LaravelStringKind::GateAbility), + _ => None, } } } else if is_instance_method { - if argument.named_argument.is_some() || argument.shape != StringArgumentShape::Scalar { - return None; - } + let receiver = trimmed_before + .trim_end_matches("?->") + .trim_end_matches("->") + .trim_end(); // Whether the receiver is `$this` (used to scope command-running // methods, whose names are too generic to match on any object). - let receiver_is_this = { - let recv = trimmed_before - .trim_end_matches("?->") - .trim_end_matches("->") - .trim_end(); - recv.ends_with("$this") - }; + let receiver_is_this = receiver.ends_with("$this"); // Whether the receiver plainly reads as the authenticated user, // which is what makes `->can('…')` an authorization check rather // than a same-named method on an unrelated object. Mirrors the // symbol-map rule that decides which `can()` calls get a span. let receiver_is_user_like = { - let recv = trimmed_before - .trim_end_matches("?->") - .trim_end_matches("->") - .trim_end(); - let tail = recv + let tail = receiver .rsplit(|c: char| !(c.is_ascii_alphanumeric() || c == '_')) .next() .unwrap_or(""); - tail.to_ascii_lowercase().ends_with("user") || recv.ends_with("user()") + tail.get(tail.len().saturating_sub(4)..) + .is_some_and(|suffix| suffix.eq_ignore_ascii_case("user")) + || receiver + .get(receiver.len().saturating_sub(6)..) + .is_some_and(|suffix| suffix.eq_ignore_ascii_case("user()")) }; // A chain that starts at the `Gate` facade // (`Gate::forUser($user)->allows('…')`) or at a route registration @@ -502,37 +1120,80 @@ fn detect_laravel_string_key_context( // rest of the chain looks like. Only the text back to the start of // the statement is searched — `trimmed_before` is the whole file // prefix, and an unrelated `Gate::` far above would false-positive. - let chain_text = &trimmed_before[trimmed_before - .rfind(['\n', ';', '{', '}']) - .map_or(0, |idx| idx + 1)..]; - let chain_starts_at_gate = chain_text.contains("Gate::"); - let chain_starts_at_route = chain_text.contains("Route::"); - let k = match func_name.to_ascii_lowercase().as_str() { - "route" | "signedroute" | "temporarysignedroute" | "redirecttoroute" | "routeis" => { - Some(LaravelStringKind::Route) - } - // `$this->call('cmd')` / `$this->callSilently('cmd')` inside a - // console command run another Artisan command. Restricted to a - // `$this` receiver because `->call()` is a common method name. - "call" | "callsilently" if receiver_is_this => Some(LaravelStringKind::Command), - // `$this->authorize('update', $post)` in a controller. - "authorize" if receiver_is_this || chain_starts_at_gate => { - Some(LaravelStringKind::GateAbility) + let is_middleware = func_name.eq_ignore_ascii_case("middleware"); + let is_can = func_name.eq_ignore_ascii_case("can") + || func_name.eq_ignore_ascii_case("cannot") + || func_name.eq_ignore_ascii_case("canAny"); + let needs_route_root = is_middleware || is_can; + let needs_gate_root = is_can + || func_name.eq_ignore_ascii_case("authorize") + || is_gate_check_method(func_name); + let (chain_start, chain_text) = if needs_route_root || needs_gate_root { + let start = receiver_chain_start(trimmed_before); + (start, &trimmed_before[start..]) + } else { + (0, "") + }; + let chain_starts_at_gate = needs_gate_root + && chain_starts_at_laravel_facade( + content, + chain_text, + chain_start, + resolved_names, + "Gate", + indexed_class_exists, + ); + let chain_starts_at_route = needs_route_root + && chain_starts_at_laravel_facade( + content, + chain_text, + chain_start, + resolved_names, + "Route", + indexed_class_exists, + ); + + if is_middleware + && chain_starts_at_route + && argument + .named_argument + .is_none_or(|name| name == "middleware") + { + let (middleware_kind, middleware_prefix, relative_start) = + middleware_completion_context(prefix)?; + prefix = middleware_prefix; + content_start_offset += relative_start; + Some(middleware_kind) + } else { + if argument.named_argument.is_some() || argument.shape != StringArgumentShape::Scalar { + return None; } - // `$user->can('update', $post)`. - "can" | "cannot" | "canany" - if receiver_is_user_like || chain_starts_at_route || chain_starts_at_gate => + if matches!( + func_name.to_ascii_lowercase().as_str(), + "route" | "signedroute" | "temporarysignedroute" | "redirecttoroute" | "routeis" + ) { + Some(LaravelStringKind::Route) + // `$this->call('cmd')` / `$this->callSilently('cmd')` inside a + // console command run another Artisan command. Restricted to a + // `$this` receiver because `->call()` is a common method name. + } else if receiver_is_this + && (func_name.eq_ignore_ascii_case("call") + || func_name.eq_ignore_ascii_case("callSilently")) { - Some(LaravelStringKind::GateAbility) - } - "allows" | "denies" | "check" | "any" | "none" | "inspect" | "has" - if chain_starts_at_gate => + Some(LaravelStringKind::Command) + // `$this->authorize('update', $post)` in a controller. + } else if (func_name.eq_ignore_ascii_case("authorize") + && (receiver_is_this || chain_starts_at_gate)) + // `$user->can('update', $post)`. + || (is_can + && (receiver_is_user_like || chain_starts_at_route || chain_starts_at_gate)) + || (is_gate_check_method(func_name) && chain_starts_at_gate) { Some(LaravelStringKind::GateAbility) + } else { + None } - _ => None, - }; - (k, None) + } } else { let fn_lower = func_name.to_ascii_lowercase(); // The Blade preprocessor lowers `@includeFirst`/`@componentFirst`/ @@ -542,35 +1203,61 @@ fn detect_laravel_string_key_context( fn_lower.as_str(), "blade_view_directive" | "blade_can_directive" ); - if argument.named_argument.is_some() - || (argument.shape != StringArgumentShape::Scalar - && (!accepts_array || !before_quote.trim_end().ends_with('['))) + if argument.shape != StringArgumentShape::Scalar + && (!accepts_array || !before_quote.trim_end().ends_with('[')) { return None; } - match fn_lower.as_str() { - "route" | "to_route" => (Some(LaravelStringKind::Route), None), - "config" => (Some(LaravelStringKind::Config), None), - "view" | "blade_view_directive" | "blade_each_directive" => { - (Some(LaravelStringKind::View), None) + let mut callable_start = name_start; + while callable_start > 0 + && (bp_bytes[callable_start - 1].is_ascii_alphanumeric() + || matches!(bp_bytes[callable_start - 1], b'_' | b'\\')) + { + callable_start -= 1; + } + let written_function = &before_paren[callable_start..name_end]; + if let Some(trigger) = crate::symbol_map::laravel_resources::auth_helper_trigger( + content, + written_function, + callable_start as u32, + resolved_names, + indexed_function_exists, + ) { + argument + .named_argument + .is_none_or(|name| name == trigger.argument) + .then_some(LaravelStringKind::ConfigResource(trigger.kind)) + } else if argument.named_argument.is_some() { + None + } else { + match fn_lower.as_str() { + "route" | "to_route" => Some(LaravelStringKind::Route), + "config" => Some(LaravelStringKind::Config), + "view" | "blade_view_directive" | "blade_each_directive" => { + Some(LaravelStringKind::View) + } + "__" | "trans" | "trans_choice" => Some(LaravelStringKind::Trans), + "env" => Some(LaravelStringKind::Env), + // The Blade preprocessor lowers `@can`/`@cannot`/`@canany` to + // this call, so completion inside the directive works too. + "blade_can_directive" => Some(LaravelStringKind::GateAbility), + _ => None, } - "__" | "trans" | "trans_choice" => (Some(LaravelStringKind::Trans), None), - "env" => (Some(LaravelStringKind::Env), None), - // The Blade preprocessor lowers `@can`/`@cannot`/`@canany` to - // this call, so completion inside the directive works too. - "blade_can_directive" => (Some(LaravelStringKind::GateAbility), None), - // auth('guard') helper accepts a guard name - "auth" => (Some(LaravelStringKind::Config), Some("auth.guards.")), - _ => (None, None), } }; let kind = kind?; + let config_sub_prefix = match &kind { + LaravelStringKind::ConfigResource(resource) => { + Some(crate::symbol_map::laravel_resources::descriptor(*resource).config_prefix) + } + _ => None, + }; Some(LaravelStringKeyContext { kind, prefix, - content_start_offset: quote_pos + 1, + content_start_offset, config_sub_prefix, }) } @@ -615,7 +1302,8 @@ impl Backend { } } - for res in &self.laravel_provider_resources.read().config_files { + let provider_configs = self.laravel_provider_resources.read().config_files.clone(); + for res in &provider_configs { if let Ok(content) = std::fs::read_to_string(&res.path) { let decls = collect_laravel_config_declarations(&content, &res.namespace); for d in decls { @@ -785,12 +1473,12 @@ impl Backend { .collect() } - pub(crate) fn cached_config_keys(&self) -> Vec { + pub(crate) fn cached_config_keys(&self) -> std::sync::Arc> { self.cached_laravel_enumeration( &self.laravel_string_key_build_locks.config_keys, |cache| cache.config_keys.clone(), |cache, keys| cache.config_keys = Some(keys), - || self.enumerate_all_config_keys(), + || std::sync::Arc::new(self.enumerate_all_config_keys()), ) } @@ -841,7 +1529,7 @@ impl Backend { /// key prefix. /// /// `file:///path/lang/en/messages.php` → `"messages"` -fn extract_lang_file_stem(uri: &str) -> Option { +pub(super) fn extract_lang_file_stem(uri: &str) -> Option { let file = uri.rsplit('/').next()?; let stem = file.strip_suffix(".php")?; if stem.is_empty() { @@ -1034,7 +1722,9 @@ fn collect_namespaced_trans_shapes_from_locale_dir( /// names is what it should look like. fn string_key_item_kind(kind: &LaravelStringKind) -> CompletionItemKind { match kind { - LaravelStringKind::Config => CompletionItemKind::PROPERTY, + LaravelStringKind::Config | LaravelStringKind::ConfigResource(_) => { + CompletionItemKind::PROPERTY + } LaravelStringKind::View => CompletionItemKind::FILE, LaravelStringKind::Trans => CompletionItemKind::TEXT, LaravelStringKind::MorphAlias => CompletionItemKind::ENUM_MEMBER, @@ -1060,14 +1750,55 @@ impl Backend { /// valid, which ordinary class completion already offers, and the set of /// keys is open besides — a list of them would read as the whole answer /// when it is not. - fn string_key_candidates(&self, kind: &LaravelStringKind) -> Vec { + fn string_key_candidates( + &self, + kind: &LaravelStringKind, + config_sub_prefix: Option<&str>, + typed_prefix: &str, + ) -> Vec { match kind { LaravelStringKind::Route => self.cached_route_names(), - // Only the keys `config/` declares: a key written at runtime is - // extracted from the same literal the cursor is inside, so the - // half-typed name of a `Storage::fake('…')` under the cursor - // would be offered back as a completion for itself. - LaravelStringKind::Config => self.cached_config_keys(), + LaravelStringKind::Config => self.cached_config_keys().as_ref().clone(), + LaravelStringKind::ConfigResource(resource) => { + let prefix = config_sub_prefix.expect("config resources always have a prefix"); + // Runtime writes can contain the half-typed name under the + // cursor, so offer only names declared by config files. + let keys = self.cached_config_keys(); + let first = keys.partition_point(|key| key.as_str() < prefix); + let mut names: Vec = keys[first..] + .iter() + .take_while(|key| key.starts_with(prefix)) + .filter_map(|key| { + let name = key.strip_prefix(prefix)?; + (!name.contains('.')).then(|| name.to_string()) + }) + .collect(); + if crate::symbol_map::laravel_resources::is_implicit_resource_name( + *resource, "null", + ) && let Err(index) = names.binary_search_by(|name| name.as_str().cmp("null")) + { + names.insert(index, "null".to_string()); + } + if *resource == LaravelConfigResource::DatabaseConnection + && typed_prefix.contains("::") + { + let mut variants = Vec::with_capacity( + names.len() + * crate::symbol_map::laravel_resources::DATABASE_ROLE_SUFFIXES.len(), + ); + for name in names { + for suffix in crate::symbol_map::laravel_resources::DATABASE_ROLE_SUFFIXES { + let mut variant = String::with_capacity(name.len() + suffix.len()); + variant.push_str(&name); + variant.push_str(suffix); + variants.push(variant); + } + } + variants + } else { + names + } + } LaravelStringKind::View => self.cached_view_names(), LaravelStringKind::Trans => self.cached_trans_keys(), LaravelStringKind::Command => self.laravel_commands.read().all_names(), @@ -1088,35 +1819,51 @@ impl Backend { /// /// Detects the cursor inside a supported string argument of `route()`, /// `config()`, `Storage::forgetDisk()`, etc. and offers matching names. + #[cfg(test)] pub(crate) fn try_laravel_string_key_completion( &self, content: &str, position: Position, ) -> Option { - let ctx = detect_laravel_string_key_context(content, position)?; - - let mut candidates = self.string_key_candidates(&ctx.kind); - - // For config-backed attributes like #[Database('mysql')], filter - // to sub-keys under the relevant config prefix and strip it so - // the user sees just the connection/store/channel name. - if let Some(sub_prefix) = ctx.config_sub_prefix { - candidates = candidates - .into_iter() - .filter_map(|key| { - key.strip_prefix(sub_prefix).and_then(|rest| { - // Only show direct children (no dots = leaf key). - if rest.contains('.') { - None - } else { - Some(rest.to_string()) - } - }) - }) - .collect(); - candidates.sort(); - candidates.dedup(); - } + self.try_laravel_string_key_completion_inner(content, position, None, None, None) + } + + /// Live-request form of Laravel string-key completion. Resolved names + /// distinguish imported facade aliases from namespace-local homonyms. + pub(crate) fn try_laravel_string_key_completion_in_file( + &self, + content: &str, + position: Position, + file_ctx: &FileContext, + ) -> Option { + let indexed_function_exists = |name: &str| self.has_indexed_function(name); + let indexed_class_exists = |name: &str| self.has_indexed_class(name); + self.try_laravel_string_key_completion_inner( + content, + position, + file_ctx.resolved_names.as_deref(), + Some(&indexed_function_exists), + Some(&indexed_class_exists), + ) + } + + fn try_laravel_string_key_completion_inner( + &self, + content: &str, + position: Position, + resolved_names: Option<&crate::names::OwnedResolvedNames>, + indexed_function_exists: Option<&dyn Fn(&str) -> bool>, + indexed_class_exists: Option<&dyn Fn(&str) -> bool>, + ) -> Option { + let ctx = detect_laravel_string_key_context_inner( + content, + position, + resolved_names, + indexed_function_exists, + indexed_class_exists, + )?; + + let candidates = self.string_key_candidates(&ctx.kind, ctx.config_sub_prefix, ctx.prefix); // Build the TextEdit range: from the start of the string content // (right after the opening quote) to the current cursor position. @@ -1128,30 +1875,26 @@ impl Backend { end: position, }; - let prefix_lower = ctx.prefix.to_lowercase(); + let prefix = ctx.prefix.as_bytes(); + let item_kind = string_key_item_kind(&ctx.kind); let items: Vec = candidates .into_iter() .filter(|name| { - if prefix_lower.is_empty() { - true - } else { - name.to_lowercase().starts_with(&prefix_lower) - } + name.as_bytes() + .get(..prefix.len()) + .is_some_and(|start| start.eq_ignore_ascii_case(prefix)) }) .enumerate() - .map(|(i, name)| { - let kind = string_key_item_kind(&ctx.kind); - CompletionItem { - label: name.clone(), - kind: Some(kind), - sort_text: Some(format!("{:05}", i)), - filter_text: Some(name.clone()), - text_edit: Some(CompletionTextEdit::Edit(TextEdit { - range: edit_range, - new_text: name, - })), - ..Default::default() - } + .map(|(i, name)| CompletionItem { + label: name.clone(), + kind: Some(item_kind), + sort_text: Some(format!("{:05}", i)), + filter_text: Some(name.clone()), + text_edit: Some(CompletionTextEdit::Edit(TextEdit { + range: edit_range, + new_text: name, + })), + ..Default::default() }) .collect(); @@ -1170,7 +1913,7 @@ mod tests { use super::*; use tower_lsp::lsp_types::Position; - fn detect_at_end(content: &str, value: &str) -> Option { + fn detect_at_end<'a>(content: &'a str, value: &str) -> Option> { let cursor = content.rfind(value)? + value.len(); detect_laravel_string_key_context( content, @@ -1201,18 +1944,70 @@ mod tests { LaravelStringKind::ContainerBinding, ] { assert!( - backend.string_key_candidates(&kind).is_empty(), + backend.string_key_candidates(&kind, None, "").is_empty(), "{kind:?} should offer no candidates" ); } } + #[test] + fn configured_resource_candidates_include_database_roles_and_null_drivers() { + let backend = crate::test_fixtures::make_backend(); + backend.laravel_string_key_cache.write().config_keys = Some(std::sync::Arc::new(vec![ + "cache.stores.redis".to_string(), + "database.connections.mysql".to_string(), + "queue.connections.sync".to_string(), + ])); + + assert_eq!( + backend.string_key_candidates(&LaravelStringKind::Config, None, ""), + [ + "cache.stores.redis", + "database.connections.mysql", + "queue.connections.sync", + ] + ); + assert_eq!( + backend.string_key_candidates( + &LaravelStringKind::ConfigResource(LaravelConfigResource::DatabaseConnection,), + Some("database.connections."), + "mysql::", + ), + ["mysql::read", "mysql::write", "mysql::direct"] + ); + for (resource, config_prefix, expected) in [ + ( + LaravelConfigResource::CacheStore, + "cache.stores.", + vec!["null", "redis"], + ), + ( + LaravelConfigResource::QueueConnection, + "queue.connections.", + vec!["null", "sync"], + ), + ] { + assert_eq!( + backend.string_key_candidates( + &LaravelStringKind::ConfigResource(resource), + Some(config_prefix), + "", + ), + expected, + ); + } + } + /// Whatever a key names decides the icon beside it. #[test] fn a_string_key_is_iconed_by_what_it_names() { use tower_lsp::lsp_types::CompletionItemKind; for (kind, expected) in [ (LaravelStringKind::Config, CompletionItemKind::PROPERTY), + ( + LaravelStringKind::ConfigResource(LaravelConfigResource::CacheStore), + CompletionItemKind::PROPERTY, + ), (LaravelStringKind::View, CompletionItemKind::FILE), (LaravelStringKind::Trans, CompletionItemKind::TEXT), ( @@ -1245,6 +2040,14 @@ mod tests { assert_eq!(ctx.prefix, "user."); } + #[test] + fn detects_instance_route_call() { + let content = "route('user.');\n"; + let ctx = detect_at_end(content, "user.").expect("should detect ->route() context"); + assert!(matches!(ctx.kind, LaravelStringKind::Route)); + assert_eq!(ctx.prefix, "user."); + } + #[test] fn detects_to_route_call() { let content = "allows('upd'", "Gate::forUser($user)->authorize('upd'", "Gate::forUser($user)->has('upd'", + "Gate::forUser($user)->inspect('upd'", // A controller's own helper. "$this->authorize('upd'", // The user the check is about. @@ -1527,7 +2331,7 @@ mod tests { for (expression, expected_kind, expected_prefix) in [ ( "DB::connection('primary')", - LaravelStringKind::Config, + LaravelStringKind::ConfigResource(LaravelConfigResource::DatabaseConnection), Some("database.connections."), ), ( @@ -1564,7 +2368,10 @@ mod tests { let content = storage_source(expression); let ctx = detect_at_end(&content, "arch") .unwrap_or_else(|| panic!("should detect `{expression}` as a disk context")); - assert!(matches!(ctx.kind, LaravelStringKind::Config)); + assert!(matches!( + ctx.kind, + LaravelStringKind::ConfigResource(LaravelConfigResource::StorageDisk) + )); assert_eq!(ctx.prefix, "arch", "prefix for `{expression}`"); assert_eq!( ctx.config_sub_prefix, @@ -1580,18 +2387,175 @@ mod tests { let content = format!(" null)->", + " Route \n ::get('/', fn () => null)->", + ] { + let content = format!("", + "Route::class && factory()->", + "Acme\\Route::get()->", + "Factory::get()->Route::get()->", + "::get()->", + "Route->get()->", + ] { + let content = format!("name('x')\n ->", + "", + " null) // keep chaining\n ->", + "", + ] { + let start = receiver_chain_start(prefix); + assert_eq!(prefix[start..].trim_start().get(..5), Some("Route")); + } + let prefix = ""; + let start = receiver_chain_start(prefix); + assert_eq!(prefix[start..].trim_start(), "unrelated()->"); + + let prefix = ""; + let start = receiver_chain_start(prefix); + assert_eq!(prefix[start..].trim_start(), "Route::get('/')->"); + } + + #[test] + fn receiver_spine_suffix_accepts_nested_and_legacy_chain_shapes() { + for suffix in [ + "get([fn () => ['value']])[0]::next()?->tail", + "get([\"close ) ] }\"])->tail", + "get(){0}->tail", + "get(function () { return ['}']; })->tail", + "get('/', /* ) ] } */ fn () => null) // continue\n ->tail", + "get() # continue\n ->tail", + ] { + assert!(is_method_chain_suffix(suffix), "suffix: {suffix}"); + } + for suffix in [ + "get() + unrelated()", + "get('unterminated)", + "get()->'invalid'", + "get()->\"invalid\"", + "get([missing)", + "get()->a()->b()->c()->d()->target", + "get()?->a()?->b()?->c()?->d()?->target", + ] { + assert!(!is_method_chain_suffix(suffix), "suffix: {suffix}"); + } + } + #[test] fn storage_facade_resolution_accepts_imports_and_rejects_homonyms() { for content in [ " new class {}), 'it\'s', 'archive' )); assert!(!string_literal_is_array_key("'key\n", "'key".len(), b'\'')); assert!(!string_literal_is_array_key("'key", "'key".len(), b'\'')); + assert!(string_literal_is_array_key( + "'key\npart'\n => 'archive'", + "'key".len(), + b'\'', + )); + assert!(string_literal_is_array_key( + "'key' /* why */\n // still a key\n # also trivia\n => 'archive'", + "'key".len(), + b'\'', + )); + assert!(!string_literal_is_array_key( + "'value' /* unterminated", + "'value".len(), + b'\'', + )); } #[test] diff --git a/src/diagnostics/mod.rs b/src/diagnostics/mod.rs index 2f4dd56f3..5486143ad 100644 --- a/src/diagnostics/mod.rs +++ b/src/diagnostics/mod.rs @@ -282,6 +282,7 @@ pub(crate) type SlowDiagnosticObserver<'a> = enum CheckedStringKind { Route, Config, + ConfigResource(crate::symbol_map::LaravelConfigResource), View, Trans, Command, @@ -677,85 +678,87 @@ impl Backend { // that write is attempted would deadlock. let mut has_route = false; let mut has_config = false; + let mut has_config_resource = false; let mut has_view = false; let mut has_trans = false; let mut has_command = false; let mut has_morph_alias = false; let mut has_gate_ability = false; - let key_spans: Vec<(CheckedStringKind, String, u32, u32)> = { - let Some(symbol_map) = self.symbol_maps.read().get(uri).cloned() else { - return; - }; - let extra = self.typed_receiver_view_spans_for(uri, &symbol_map); - symbol_map - .spans - .iter() - .chain(extra.iter()) - .filter_map(|span| { - if let SymbolKind::LaravelStringKey { - kind, - key, - is_write, - is_optional, - } = &span.kind - { - // A write declares the key it names, so there is - // nothing to check it against, and an optional key - // is one the call is written to do without: an - // `@includeFirst` candidate that names nothing is - // why the directive takes a list at all. - if *is_write || *is_optional { - return None; - } - let checked = match kind { - LaravelStringKind::Route => { - has_route = true; - CheckedStringKind::Route - } - LaravelStringKind::Config => { - has_config = true; - CheckedStringKind::Config - } - LaravelStringKind::View => { - has_view = true; - CheckedStringKind::View - } - LaravelStringKind::Trans => { - has_trans = true; - CheckedStringKind::Trans - } - LaravelStringKind::Command => { - has_command = true; - CheckedStringKind::Command - } - LaravelStringKind::MorphAlias => { - has_morph_alias = true; - CheckedStringKind::MorphAlias - } - LaravelStringKind::GateAbility => { - has_gate_ability = true; - CheckedStringKind::GateAbility - } - // A section or stack name is judged against the - // templates that render the one it is written - // in, which the Blade pass below has and this - // one does not. And anything at all can be bound - // at runtime, so an unrecognised container key - // proves nothing — nor does an environment - // variable absent from `.env`, since the - // environment a process runs with is not on disk. - LaravelStringKind::Section - | LaravelStringKind::Stack - | LaravelStringKind::ContainerBinding - | LaravelStringKind::Env => return None, - }; - Some((checked, key.clone(), span.start, span.end)) - } else { - None - } - }) - .collect() + let Some(symbol_map) = self.symbol_maps.read().get(uri).cloned() else { + return; }; + let extra = self.typed_receiver_view_spans_for(uri, &symbol_map); + let key_spans: Vec<(CheckedStringKind, &str, u32, u32)> = symbol_map + .spans + .iter() + .chain(extra.iter()) + .filter_map(|span| { + if let SymbolKind::LaravelStringKey { + kind, + key, + is_write, + is_optional, + } = &span.kind + { + // A write declares the key it names, so there is + // nothing to check it against, and an optional key + // is one the call is written to do without: an + // `@includeFirst` candidate that names nothing is + // why the directive takes a list at all. + if *is_write || *is_optional { + return None; + } + let checked = match kind { + LaravelStringKind::Route => { + has_route = true; + CheckedStringKind::Route + } + LaravelStringKind::Config => { + has_config = true; + CheckedStringKind::Config + } + LaravelStringKind::ConfigResource(resource) => { + has_config = true; + has_config_resource = true; + CheckedStringKind::ConfigResource(*resource) + } + LaravelStringKind::View => { + has_view = true; + CheckedStringKind::View + } + LaravelStringKind::Trans => { + has_trans = true; + CheckedStringKind::Trans + } + LaravelStringKind::Command => { + has_command = true; + CheckedStringKind::Command + } + LaravelStringKind::MorphAlias => { + has_morph_alias = true; + CheckedStringKind::MorphAlias + } + LaravelStringKind::GateAbility => { + has_gate_ability = true; + CheckedStringKind::GateAbility + } + // A section or stack name is judged against the + // templates that render the one it is written + // in, which the Blade pass below has and this + // one does not. And anything at all can be bound + // at runtime, so an unrecognised container key + // proves nothing. + LaravelStringKind::Section + | LaravelStringKind::Stack + | LaravelStringKind::ContainerBinding + | LaravelStringKind::Env => return None, + }; + Some((checked, key.as_str(), span.start, span.end)) + } else { + None + } + }) + .collect(); if !has_route && !has_config @@ -796,11 +799,8 @@ impl Backend { // see, so none of its keys can be judged. Only an application owns // the whole of its configuration. let has_config = has_config && self.is_application_project(); - let declared_config_keys: Vec = if has_config { - self.cached_config_keys() - } else { - Vec::new() - }; + let cached_config_keys = has_config.then(|| self.cached_config_keys()); + let declared_config_keys = cached_config_keys.as_deref().map_or(&[][..], Vec::as_slice); // A key that `Config::set()` or the array form of the `config()` // helper establishes is as real as one a `config/` file declares; a // test that configures a disk in `setUp()` before exercising it is @@ -810,6 +810,17 @@ impl Backend { } else { HashSet::new() }; + // A runtime write establishes its ancestors while leaving the value + // and all descendants opaque. Match whole segments in either direction. + let config_paths_overlap = |left: &str, right: &str| { + left == right + || left + .strip_prefix(right) + .is_some_and(|rest| rest.starts_with('.')) + || right + .strip_prefix(left) + .is_some_and(|rest| rest.starts_with('.')) + }; // The config files we managed to enumerate keys from, by name. A // key whose root segment names none of them lives in a file we // cannot see, so nothing about it is knowable. A runtime write is @@ -820,11 +831,14 @@ impl Backend { .iter() .map(|key| key.split('.').next().unwrap_or(key.as_str())) .collect(); - let config_keys: HashSet<&str> = declared_config_keys - .iter() - .chain(written_config_keys.iter()) - .map(String::as_str) - .collect(); + let config_resource_mask = if has_config_resource { + declared_config_keys.iter().fold(0, |mask, key| { + crate::symbol_map::laravel_resources::resource_from_config_key(key) + .map_or(mask, |(resource, _)| mask | resource.bit()) + }) + } else { + 0 + }; let view_keys: HashSet = if has_view { self.cached_view_names().into_iter().collect() } else { @@ -887,7 +901,7 @@ impl Backend { HashSet::new() }; - for (kind, key, start, end) in &key_spans { + for &(kind, key, start, end) in &key_spans { let (valid, label, code) = match kind { // An ability is judged against the model the check names, so // it reports which model rather than the shared @@ -896,12 +910,12 @@ impl Backend { if !gate_ability_space_is_open && !gate_abilities.is_empty() && let Some(message) = - self.gate_ability_problem(uri, content, key, *start, &gate_abilities) + self.gate_ability_problem(uri, content, key, start, &gate_abilities) && let Some(range) = self.offset_range_to_lsp_range( uri, content, - *start as usize, - *end as usize, + start as usize, + end as usize, ) { out.push(helpers::make_diagnostic( @@ -957,23 +971,61 @@ impl Backend { // An unknown root means the file never reached us, so // the key cannot be wrong as far as we can tell, while // a typo inside a file we did read is still caught. - if !config_roots.contains(key.split('.').next().unwrap_or(key.as_str())) { + if !config_roots.contains(key.split('.').next().unwrap_or(key)) { continue; } // Config keys may be partial prefixes (e.g. `config('app')`) - // which are valid even without a direct match. The other - // direction holds for a key written at runtime: the value - // it stored is opaque to us, so every path under it is - // beyond judging as well. - let valid = config_keys.contains(key.as_str()) - || config_keys - .iter() - .any(|k| k.starts_with(&format!("{}.", key))) + // which are valid even without a direct match. Runtime + // writes also make their opaque descendants unjudgeable. + let candidate = declared_config_keys.get( + declared_config_keys.partition_point(|candidate| candidate.as_str() < key), + ); + let valid = candidate.is_some_and(|candidate| { + candidate == key + || candidate + .strip_prefix(key) + .is_some_and(|suffix| suffix.starts_with('.')) + }) || written_config_keys + .iter() + .any(|written| config_paths_overlap(key, written)); + (valid, "config key", "invalid_laravel_config") + } + CheckedStringKind::ConfigResource(resource) => { + let descriptor = crate::symbol_map::laravel_resources::descriptor(resource); + if crate::symbol_map::laravel_resources::is_implicit_resource_name( + resource, key, + ) { + continue; + } + // An undiscovered subtree is an unknown vocabulary, not + // proof that every runtime-provided name is invalid. + if config_resource_mask & resource.bit() == 0 { + continue; + } + let prefix = descriptor.config_prefix; + let first = declared_config_keys + .partition_point(|candidate| candidate.as_str() < prefix); + let child = + crate::symbol_map::laravel_resources::configured_child_name(resource, key); + let valid = declared_config_keys[first..] + .iter() + .take_while(|candidate| candidate.starts_with(prefix)) + .any(|candidate| { + crate::symbol_map::laravel_resources::matches_config_key( + resource, key, candidate, + ) + }) || written_config_keys.iter().any(|written| { - key.strip_prefix(written.as_str()) - .is_some_and(|rest| rest.starts_with('.')) + written.strip_prefix(prefix).map_or_else( + || { + prefix + .strip_prefix(written.as_str()) + .is_some_and(|rest| rest.starts_with('.')) + }, + |written_child| config_paths_overlap(child, written_child), + ) }); - (valid, "config key", "invalid_laravel_config") + (valid, descriptor.label, descriptor.diagnostic_code) } CheckedStringKind::View => { (view_keys.contains(key), "view", "invalid_laravel_view") @@ -987,9 +1039,11 @@ impl Backend { continue; } let valid = trans_keys.contains(key) - || trans_keys - .iter() - .any(|k| k.starts_with(&format!("{}.", key))); + || trans_keys.iter().any(|candidate| { + candidate + .strip_prefix(key) + .is_some_and(|suffix| suffix.starts_with('.')) + }); (valid, "translation key", "invalid_laravel_trans") } CheckedStringKind::Command => { @@ -1020,7 +1074,7 @@ impl Backend { }; if !valid && let Some(range) = - self.offset_range_to_lsp_range(uri, content, *start as usize, *end as usize) + self.offset_range_to_lsp_range(uri, content, start as usize, end as usize) { out.push(helpers::make_diagnostic( range, @@ -1979,6 +2033,20 @@ mod tests { ); } + #[test] + fn laravel_string_key_diagnostics_ignore_an_unindexed_uri() { + let backend = crate::Backend::new_test(); + let mut out = Vec::new(); + + backend.collect_invalid_laravel_string_key_diagnostics( + "file:///closed.php", + " { + let descriptor = crate::symbol_map::laravel_resources::descriptor(*resource); + let locations = crate::virtual_members::laravel::resolve_laravel_string_key( + self, kind, key, uri, + ); + let detail = locations + .first() + .and_then(|location| self.workspace_relative_path(location.uri.as_str())) + .map_or_else( + || format!("Laravel {}", descriptor.label), + |path| format!("Defined in `{path}`"), + ); + (descriptor.hover_label, detail) + } LaravelStringKind::View => { let locations = crate::virtual_members::laravel::resolve_laravel_string_key( self, kind, key, uri, diff --git a/src/indexing/scan.rs b/src/indexing/scan.rs index a793d8012..adfd6b340 100644 --- a/src/indexing/scan.rs +++ b/src/indexing/scan.rs @@ -191,6 +191,11 @@ impl Backend { self.clear_class_not_found_cache(); self.resolved_class_cache.write().clear(); self.member_completion_cache.lock().clear(); + + // Composer changes can introduce or remove functions/classes that + // shadow Laravel's runtime helper and facade aliases. Only maps with + // such candidates are revisited. + self.refresh_all_published_laravel_candidates(); } /// Scan autoload files for a single project root and populate the diff --git a/src/lib.rs b/src/lib.rs index 4421722b3..08963d3c7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -347,7 +347,10 @@ pub(crate) struct LaravelStringKeyCache { /// `Arc` because both the name list and the parameter names of one route /// are read from it, and cloning the whole table per read would be waste. pub routes: Option>, - pub config_keys: Option>, + /// Config keys are shared by completion and the parallel diagnostic pass. + /// Keeping the immutable sorted list behind an `Arc` avoids cloning every + /// key on each resource-name keystroke or file diagnostic. + pub config_keys: Option>>, pub view_names: Option>, pub trans_keys: Option>, /// Every translation key mapped to whether it names a group (nested @@ -1878,6 +1881,11 @@ impl Backend { } } } + + // The refreshed discovery indexes may add or remove a namespace-local + // `auth()` or a real global class that shadows a Laravel facade alias. + // Re-evaluate only maps that recorded one of those dormant candidates. + self.refresh_all_published_laravel_candidates(); } /// Create a shallow clone of this `Backend` that shares every diff --git a/src/mem_audit.rs b/src/mem_audit.rs index 424c8e0a1..6ef72c227 100644 --- a/src/mem_audit.rs +++ b/src/mem_audit.rs @@ -1286,6 +1286,13 @@ pub(crate) fn report(backend: &Backend, runner_content_bytes: usize) { for sp in &sm.spans { sym.add(sp.kind.audit_heap()); } + sym.add( + sm.conditional_laravel_spans.capacity() + * size_of::(), + ); + for candidate in &sm.conditional_laravel_spans { + sym.add(candidate.audit_heap()); + } sym += map_buckets::>(sm.member_access_indices.capacity()); member_idx += map_buckets::>(sm.member_access_indices.capacity()); for v in sm.member_access_indices.values() { @@ -1436,10 +1443,10 @@ pub(crate) fn report(backend: &Backend, runner_content_bytes: usize) { let mut laravel_keys = Sz::default(); { let c = backend.laravel_string_key_cache.read(); - for v in [&c.config_keys, &c.view_names, &c.trans_keys] - .into_iter() - .flatten() - { + if let Some(keys) = &c.config_keys { + laravel_keys += vs(keys); + } + for v in [&c.view_names, &c.trans_keys].into_iter().flatten() { laravel_keys += vs(v); } if let Some(discovery) = &c.routes { diff --git a/src/parser/ast_update.rs b/src/parser/ast_update.rs index 6c02fe68a..27c031bb0 100644 --- a/src/parser/ast_update.rs +++ b/src/parser/ast_update.rs @@ -7,7 +7,7 @@ /// helpers (`resolve_parent_class_names`, `resolve_name`) used to convert /// short class names to fully-qualified names. use std::cell::RefCell; -use std::collections::{BTreeMap, HashMap}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::sync::Arc; use crate::ParseErrorEntry; @@ -15,7 +15,7 @@ use crate::atom::{Atom, atom, bytes_to_str}; use crate::ci_map::CiMap; use crate::names::OwnedResolvedNames; use crate::php_type::PhpType; -use crate::symbol_map::{SymbolMap, extract_symbol_map}; +use crate::symbol_map::{LaravelStringDependency, SymbolMap, extract_symbol_map_for_index}; use crate::types::{ ClassInfo, DefineInfo, DocblockMembers, FunctionInfo, MethodInfo, NamespaceSpan, TypeAliasDef, }; @@ -178,6 +178,71 @@ fn withdraw_function( } impl Backend { + /// Clone and reconcile only published maps whose dormant Laravel spans + /// depend on one of `affected`. Passing `None` is reserved for rare + /// workspace purges where several declaration indexes changed together. + fn refreshed_laravel_candidate_maps( + &self, + affected: Option<&HashSet>, + excluded_uris: &HashSet<&str>, + ) -> Vec<(String, Arc)> { + let candidates: Vec<(String, Arc)> = self + .symbol_maps + .read() + .iter() + .filter(|(uri, _)| !excluded_uris.contains(uri.as_str())) + .filter(|(_, map)| map.has_conditional_laravel_dependency(affected)) + .map(|(uri, map)| (uri.clone(), Arc::clone(map))) + .collect(); + let mut dependency_presence = HashMap::new(); + candidates + .into_iter() + .filter_map(|(uri, map)| { + let mut dependency_exists = |dependency| { + *dependency_presence + .entry(dependency) + .or_insert_with(|| match dependency { + LaravelStringDependency::Function(name) => { + self.has_indexed_function(name.as_str()) + } + LaravelStringDependency::Class(name) => { + self.has_indexed_class(name.as_str()) + } + }) + }; + if !map.conditional_laravel_spans_need_refresh(affected, &mut dependency_exists) { + return None; + } + let mut refreshed = map.as_ref().clone(); + let changed = + refreshed.refresh_conditional_laravel_spans(affected, dependency_exists); + debug_assert!(changed); + Some((uri, Arc::new(refreshed))) + }) + .collect() + } + + /// Publish refreshed conditional maps and keep their reference-index + /// entries in the same generation. Used after discovery-index rebuilds; + /// normal AST batches fold refreshed maps into their existing reindex. + pub(crate) fn refresh_all_published_laravel_candidates(&self) -> bool { + let refreshed = self.refreshed_laravel_candidate_maps(None, &HashSet::new()); + if refreshed.is_empty() { + return false; + } + self.reindex_references_for_symbol_maps_batch(refreshed.clone()); + self.refresh_laravel_config_writes( + refreshed + .iter() + .map(|(uri, map)| (uri.as_str(), map.as_ref())), + ); + let mut symbol_maps = self.symbol_maps.write(); + for (uri, map) in refreshed { + symbol_maps.insert(uri, map); + } + true + } + /// Drop every function declaration contributed by `uris`, handing each /// name to the next-lowest file that still declares it. /// @@ -266,12 +331,6 @@ impl Backend { // files that mention neither `Gate` nor `$policies`. self.refresh_laravel_gates(uri, content); - // Keep the set of config keys the project declares at runtime - // coherent with edits to the files that write them. Reads the spans - // the parse above just stored, so it runs after it rather than - // alongside the token-gated refreshes. - self.refresh_laravel_config_writes(uri); - // Keep the container bindings, package config files, view and // translation directories, route files, and component namespaces // coherent with edits to the providers that register them. Cheap @@ -873,7 +932,11 @@ impl Backend { // Build the precomputed symbol map while the AST is still alive. // This must happen before the `Program` (and its arena) are dropped. - let symbol_map = Arc::new(extract_symbol_map(program, content)); + let symbol_map = Arc::new(extract_symbol_map_for_index( + program, + content, + &owned_resolved, + )); // For files without any explicit namespace blocks, synthesize a // single span covering the entire file with the detected namespace @@ -1370,9 +1433,9 @@ impl Backend { ); } - let changed = any_signature_changed || any_function_changed; + let structural_changed = any_signature_changed || any_function_changed; - if changed { + if structural_changed { self.member_completion_cache.lock().clear(); // A receiver's type is settled against the classes of the whole // workspace, so a signature change anywhere can turn a call that @@ -1384,14 +1447,87 @@ impl Backend { } } - let reference_items: Vec<(String, Arc)> = prepared + // The batch declarations are now visible. Activate its prospective + // Laravel spans against that final state, not the incomplete index + // that happened to exist while parallel workers parsed the files. + { + let mut dependency_presence = HashMap::new(); + for update in &mut prepared { + Arc::make_mut(&mut update.symbol_map).refresh_conditional_laravel_spans( + None, + |dependency| { + *dependency_presence + .entry(dependency) + .or_insert_with(|| match dependency { + LaravelStringDependency::Function(name) => { + self.has_indexed_function(name.as_str()) + } + LaravelStringDependency::Class(name) => { + self.has_indexed_class(name.as_str()) + } + }) + }, + ); + } + } + + // Only declaration names that can gate a candidate trigger a scan of + // the published maps. Function signatures and ordinary class edits + // therefore retain the existing O(batch) publication cost. + let mut affected_laravel_dependencies = HashSet::new(); + for fqn in all_old_fqns.iter().chain(&all_new_fqns) { + if let Some(dependency) = LaravelStringDependency::root_facade(fqn) { + affected_laravel_dependencies.insert(dependency); + } + } + for update in &prepared { + for fqn in update + .old_function_fqns + .iter() + .chain(&update.new_function_fqns) + { + if let Some(dependency) = LaravelStringDependency::namespaced_auth(fqn) { + affected_laravel_dependencies.insert(dependency); + } + } + } + let prepared_uris: HashSet<&str> = + prepared.iter().map(|update| update.uri.as_str()).collect(); + let refreshed_existing = if affected_laravel_dependencies.is_empty() { + Vec::new() + } else { + self.refreshed_laravel_candidate_maps( + Some(&affected_laravel_dependencies), + &prepared_uris, + ) + }; + let changed = structural_changed || !refreshed_existing.is_empty(); + + let mut reference_items: Vec<(String, Arc)> = prepared .iter() .map(|update| (update.uri.clone(), Arc::clone(&update.symbol_map))) .collect(); + reference_items.extend(refreshed_existing.iter().cloned()); self.reindex_references_for_symbol_maps_batch(reference_items); + // Runtime writes follow every published map, including unedited + // files whose facade alias was shadowed or restored by this batch. + self.refresh_laravel_config_writes( + prepared + .iter() + .map(|update| (update.uri.as_str(), update.symbol_map.as_ref())) + .chain( + refreshed_existing + .iter() + .map(|(uri, map)| (uri.as_str(), map.as_ref())), + ), + ); + { let mut symbol_maps = self.symbol_maps.write(); + for (uri, map) in refreshed_existing { + symbol_maps.insert(uri, map); + } for update in prepared { symbol_maps.insert(update.uri, update.symbol_map); } @@ -1996,6 +2132,359 @@ mod tests { use super::*; use crate::Backend; + fn has_config_resource_span( + backend: &Backend, + uri: &str, + key: &str, + resource: crate::symbol_map::LaravelConfigResource, + ) -> bool { + backend.symbol_map_for(uri).is_some_and(|map| { + map.spans.iter().any(|span| { + matches!( + &span.kind, + crate::symbol_map::SymbolKind::LaravelStringKey { + kind: crate::symbol_map::LaravelStringKind::ConfigResource(found), + key: found_key, + .. + } if *found == resource && found_key == key + ) + }) + }) + } + + fn reference_index_has_config_resource( + backend: &Backend, + uri: &str, + key: &str, + resource: crate::symbol_map::LaravelConfigResource, + ) -> bool { + let index_key = crate::reference_index::laravel_string_reference_key( + crate::symbol_map::LaravelStringKind::ConfigResource(resource), + key, + ); + backend + .reference_index + .read() + .get(&index_key) + .is_some_and(|entries| entries.keys().any(|entry_uri| entry_uri.as_ref() == uri)) + } + + #[test] + fn namespaced_auth_candidates_follow_cross_file_function_lifecycle() { + use crate::symbol_map::LaravelConfigResource::AuthGuard; + + let backend = Backend::new_test(); + let consumer_uri = "file:///app/Consumer.php"; + let helper_uri = "file:///app/helpers.php"; + backend.update_ast(consumer_uri, "where('id')->middleware('auth:route');\n", + ); + assert!(has_config_resource_span( + &backend, + consumer_uri, + "redis", + CacheStore + )); + assert!(has_config_resource_span( + &backend, + consumer_uri, + "local", + StorageDisk + )); + assert!(has_config_resource_span( + &backend, + consumer_uri, + "route", + crate::symbol_map::LaravelConfigResource::AuthGuard + )); + + backend.update_ast( + class_uri, + " []]);\nConfig::set('filesystems.disks.temporary', []);\nStorage::fake('temporary');\nStorage::persistentFake('persistent');\n", + ); + let reader = backend.parse_ast_index_update_for_index( + reader_uri, + "middleware('auth:web');\n $this->middleware('can:update,Post');\n }\n}\nRoute::get('/')->middleware('auth:admin');\nRoute::get('/')->a()->b()->c()->d()->middleware('auth:too-deep');\n", + ); + assert!(!has_config_resource_span(&backend, uri, "web", AuthGuard)); + assert!(has_config_resource_span(&backend, uri, "admin", AuthGuard)); + assert!(!has_config_resource_span( + &backend, uri, "too-deep", AuthGuard + )); + assert!(backend.symbol_map_for(uri).is_some_and(|map| { + map.spans.iter().any(|span| { + matches!( + &span.kind, + crate::symbol_map::SymbolKind::LaravelStringKey { + kind: crate::symbol_map::LaravelStringKind::GateAbility, + key, + .. + } if key == "update" + ) + }) + })); + } + /// Changing a function's parameter type should cause `update_ast` to /// return `true` (signature changed), triggering cross-file /// diagnostic invalidation. This is the exact scenario from diff --git a/src/reference_index.rs b/src/reference_index.rs index 4b7b6d6f9..8b21f672e 100644 --- a/src/reference_index.rs +++ b/src/reference_index.rs @@ -47,6 +47,33 @@ pub(crate) enum ReferenceIndexKey { }, } +/// Build the one index identity shared by a Laravel string-key usage and its +/// config-backed aliases. +/// +/// Resource spans deliberately retain their short source text (for example, +/// `redis`), while the reference index stores the canonical config address +/// (`cache.stores.redis`). This keeps one entry per semantic identity without +/// changing the exact source range consumers return. +pub(crate) fn laravel_string_reference_key( + kind: LaravelStringKind, + key: &str, +) -> ReferenceIndexKey { + match kind { + LaravelStringKind::ConfigResource(resource) + if !crate::symbol_map::laravel_resources::is_implicit_resource_name(resource, key) => + { + ReferenceIndexKey::LaravelString { + kind: LaravelStringKind::Config, + key: crate::symbol_map::laravel_resources::config_key(resource, key), + } + } + _ => ReferenceIndexKey::LaravelString { + kind, + key: key.to_string(), + }, + } +} + impl ReferenceIndexKey { /// The key a class is indexed under, case-folded to match PHP's /// case-insensitive class resolution. @@ -466,13 +493,7 @@ impl Backend { )] } SymbolKind::LaravelStringKey { kind, key, .. } => { - vec![( - ReferenceIndexKey::LaravelString { - kind: kind.clone(), - key: key.to_string(), - }, - true, - )] + vec![(laravel_string_reference_key(*kind, key), true)] } _ => Vec::new(), } @@ -706,7 +727,7 @@ mod tests { use super::*; use crate::Backend; - use crate::symbol_map::{SymbolMap, SymbolSpan}; + use crate::symbol_map::{LaravelConfigResource, SymbolMap, SymbolSpan}; #[test] fn candidate_lookup_is_disabled_until_workspace_is_indexed() { @@ -773,6 +794,89 @@ mod tests { ); } + #[test] + fn config_resources_and_generic_config_keys_share_one_canonical_candidate_key() { + let backend = Backend::new_test(); + backend.workspace_indexed.store(true, Ordering::Release); + let resource_uri = "file:///project/src/CacheConsumer.php"; + let config_uri = "file:///project/src/ConfigConsumer.php"; + let queue_uri = "file:///project/src/QueueConsumer.php"; + + backend.reindex_references_for_symbol_maps_batch(vec![ + ( + resource_uri.to_string(), + laravel_string_map( + LaravelStringKind::ConfigResource(LaravelConfigResource::CacheStore), + "redis", + ), + ), + ( + config_uri.to_string(), + laravel_string_map(LaravelStringKind::Config, "cache.stores.redis"), + ), + ( + queue_uri.to_string(), + laravel_string_map( + LaravelStringKind::ConfigResource(LaravelConfigResource::QueueConnection), + "redis", + ), + ), + ]); + + let resource_key = laravel_string_reference_key( + LaravelStringKind::ConfigResource(LaravelConfigResource::CacheStore), + "redis", + ); + let config_key = + laravel_string_reference_key(LaravelStringKind::Config, "cache.stores.redis"); + assert_eq!(resource_key, config_key); + assert_candidate_contains(&backend, config_key.clone(), resource_uri); + assert_candidate_contains(&backend, config_key, config_uri); + let queue_key = laravel_string_reference_key( + LaravelStringKind::ConfigResource(LaravelConfigResource::QueueConnection), + "redis", + ); + assert_candidate_contains(&backend, queue_key.clone(), queue_uri); + assert_candidate_not_contains(&backend, queue_key, resource_uri); + + let index = backend.reference_index.read(); + assert_eq!(index.by_key.len(), 2); + assert_eq!(index.uri_keys.get(resource_uri).map(Vec::len), Some(1)); + assert_eq!(index.uri_keys.get(config_uri).map(Vec::len), Some(1)); + assert_eq!(index.uri_keys.get(queue_uri).map(Vec::len), Some(1)); + } + + #[test] + fn non_config_laravel_string_keys_keep_their_original_identity() { + assert_eq!( + laravel_string_reference_key(LaravelStringKind::View, "dashboard"), + ReferenceIndexKey::LaravelString { + kind: LaravelStringKind::View, + key: "dashboard".to_string(), + } + ); + assert_eq!( + laravel_string_reference_key( + LaravelStringKind::ConfigResource(LaravelConfigResource::CacheStore), + "null", + ), + ReferenceIndexKey::LaravelString { + kind: LaravelStringKind::ConfigResource(LaravelConfigResource::CacheStore), + key: "null".to_string(), + } + ); + assert_eq!( + laravel_string_reference_key( + LaravelStringKind::ConfigResource(LaravelConfigResource::DatabaseConnection), + "mysql::read", + ), + ReferenceIndexKey::LaravelString { + kind: LaravelStringKind::Config, + key: "database.connections.mysql".to_string(), + } + ); + } + #[test] fn reference_index_evicts_candidates_when_file_maps_clear() { let backend = Backend::new_test(); @@ -1094,4 +1198,20 @@ mod tests { ..SymbolMap::default() }) } + + fn laravel_string_map(kind: LaravelStringKind, key: &str) -> Arc { + Arc::new(SymbolMap { + spans: vec![SymbolSpan { + start: 0, + end: key.len() as u32, + kind: SymbolKind::LaravelStringKey { + kind, + key: key.to_string(), + is_write: false, + is_optional: false, + }, + }], + ..SymbolMap::default() + }) + } } diff --git a/src/references/dispatch.rs b/src/references/dispatch.rs index 277fedcae..7fa767633 100644 --- a/src/references/dispatch.rs +++ b/src/references/dispatch.rs @@ -353,18 +353,12 @@ impl Backend { if !self.resolved_class_cache.read().is_laravel() { return Vec::new(); } - let snapshot = if include_declaration - && matches!(kind, crate::symbol_map::LaravelStringKind::Config) - { - self.user_file_symbol_maps() - } else { - self.user_file_symbol_maps_for_reference_keys(&[ - ReferenceIndexKey::LaravelString { - kind: kind.clone(), - key: key.to_string(), - }, - ]) - }; + // Config resources and their generic config spelling share a + // canonical index identity. Declaration lookup is independent + // of this usage snapshot, so every request stays narrow. + let reference_key = + crate::reference_index::laravel_string_reference_key(*kind, key); + let snapshot = self.user_file_symbol_maps_for_reference_keys(&[reference_key]); laravel::find_laravel_string_key_references( self, kind, diff --git a/src/rename/prepare.rs b/src/rename/prepare.rs index d2ffd0764..8d148f7c5 100644 --- a/src/rename/prepare.rs +++ b/src/rename/prepare.rs @@ -189,6 +189,14 @@ impl Backend { let class_rename_fqn = self.resolve_class_rename_fqn(&span.kind, uri, span.start); + // Direct resource APIs spell only the child name while generic config + // calls spell its full dotted key. Until Laravel string-key rename is + // implemented, applying one replacement to their shared reference set + // would corrupt one representation. + if is_config_resource_identity(&span.kind) { + return Ok(None); + } + // Find all references (including the declaration). let Some(locations) = self.find_references_for_rename(uri, content, position, true) else { return Ok(None); @@ -452,3 +460,52 @@ impl Backend { } } } + +fn is_config_resource_identity(kind: &SymbolKind) -> bool { + let SymbolKind::LaravelStringKey { kind, key, .. } = kind else { + return false; + }; + match kind { + crate::symbol_map::LaravelStringKind::ConfigResource(_) => true, + crate::symbol_map::LaravelStringKind::Config => { + crate::symbol_map::laravel_resources::resource_from_config_key(key).is_some() + } + _ => false, + } +} + +#[cfg(test)] +mod config_resource_identity_tests { + use super::*; + use crate::symbol_map::{LaravelConfigResource, LaravelStringKind}; + + fn string_kind(kind: LaravelStringKind, key: &str) -> SymbolKind { + SymbolKind::LaravelStringKey { + kind, + key: key.to_string(), + is_write: false, + is_optional: false, + } + } + + #[test] + fn only_config_resource_identities_are_held_for_laravel_string_rename() { + assert!(is_config_resource_identity(&string_kind( + LaravelStringKind::ConfigResource(LaravelConfigResource::CacheStore), + "redis", + ))); + assert!(is_config_resource_identity(&string_kind( + LaravelStringKind::Config, + "cache.stores.redis", + ))); + assert!(!is_config_resource_identity(&string_kind( + LaravelStringKind::Config, + "app.name", + ))); + assert!(!is_config_resource_identity(&string_kind( + LaravelStringKind::View, + "dashboard", + ))); + assert!(!is_config_resource_identity(&SymbolKind::Keyword)); + } +} diff --git a/src/resolution.rs b/src/resolution.rs index bf51e11b2..8459908de 100644 --- a/src/resolution.rs +++ b/src/resolution.rs @@ -1254,6 +1254,30 @@ impl Backend { self.resolve_function_name_at(name, None, 0, file_use_map, file_namespace) } + /// Whether discovery or full parsing has seen this exact function FQN. + /// + /// This is the allocation-free membership form used when a caller only + /// needs to know whether PHP's namespace-local function shadows a global + /// fallback. It deliberately does not trigger a lazy parse. + pub(crate) fn has_indexed_function(&self, fqn: &str) -> bool { + self.symbols.global_functions.read().get(fqn).is_some() + || self + .symbols + .autoload_function_index + .read() + .get(fqn) + .is_some() + } + + /// Whether discovery or full parsing has seen this exact class FQN. + /// + /// Laravel's optional root facade aliases are used only when no real + /// global class owns the same name. This membership check does not lazy + /// load the class, keeping completion on that fallback allocation-free. + pub(crate) fn has_indexed_class(&self, fqn: &str) -> bool { + self.symbols.fqn_uri_index.read().get(fqn).is_some() + } + /// Resolve a function name, consulting mago-names' per-offset /// resolution for the authoritative fully-qualified name. /// diff --git a/src/symbol_map/extraction/class_like.rs b/src/symbol_map/extraction/class_like.rs index bec4afc59..c7d3b6bcf 100644 --- a/src/symbol_map/extraction/class_like.rs +++ b/src/symbol_map/extraction/class_like.rs @@ -304,30 +304,36 @@ pub(super) fn extract_from_attribute_lists<'a>( // Laravel container attributes: #[Config('key')], // #[Database('conn')], #[Cache('store')], etc. → - // emit a LaravelStringKey::Config span so hover, - // go-to-definition, and diagnostics work on the key. + // emit a config-key or named-resource span so hover, + // go-to-definition, and diagnostics work on the name. // - // FQN attributes match directly. Short names require - // the file to import from the Illuminate namespace; - // that check is cached once per file to avoid repeated - // linear scans. + // Semantic names resolve imports and aliases exactly. The + // syntax-only fallback caches its import check per file. + let semantic_class_name = ctx + .resolved_name_at(attr.name.span().start.offset) + .unwrap_or(class_name); if let Some(attribute) = resolve_laravel_container_attr( - class_name, + semantic_class_name, + ctx.resolved_names.is_none(), &mut ctx.has_laravel_container_attrs, ctx.content, ) { match attribute { - LaravelContainerAttribute::Config => { - try_emit_laravel_string_span_partial( - crate::symbol_map::LaravelStringKind::Config, + LaravelContainerAttribute::Resource(trigger) => { + try_emit_laravel_config_resource_span_partial_for_parameter( + trigger.kind, + trigger.access, arg_list, + trigger.argument, ctx.content, &mut ctx.spans, ); } - LaravelContainerAttribute::StorageDisk => { - try_emit_laravel_storage_disk_span_partial( + LaravelContainerAttribute::Config => { + try_emit_laravel_string_span_partial_for_parameter( + crate::symbol_map::LaravelStringKind::Config, arg_list, + "key", ctx.content, &mut ctx.spans, ); @@ -342,9 +348,10 @@ pub(super) fn extract_from_attribute_lists<'a>( &mut ctx.has_laravel_http_attrs, ctx.content, ) { - try_emit_laravel_string_span_partial( + try_emit_laravel_string_span_partial_for_parameter( crate::symbol_map::LaravelStringKind::Route, arg_list, + "route", ctx.content, &mut ctx.spans, ); diff --git a/src/symbol_map/extraction/expressions/calls.rs b/src/symbol_map/extraction/expressions/calls.rs index f5ec0dbef..2b5fd0476 100644 --- a/src/symbol_map/extraction/expressions/calls.rs +++ b/src/symbol_map/extraction/expressions/calls.rs @@ -373,6 +373,30 @@ fn extract_call<'a>( &mut ctx.spans, ); } + if let Some(trigger) = crate::symbol_map::laravel_resources::auth_helper_trigger( + ctx.content, + name_clean, + ident.span().start.offset, + ctx.resolved_names, + None, + ) { + let span_start = ctx.spans.len(); + try_emit_laravel_config_resource_span_for_parameter( + trigger.kind, + trigger.shape, + trigger.access, + &func_call.argument_list, + trigger.argument, + ctx.content, + &mut ctx.spans, + ); + if let Some(dependency) = ctx + .resolved_name_at(ident.span().start.offset) + .and_then(crate::symbol_map::LaravelStringDependency::namespaced_auth) + { + ctx.defer_laravel_spans_since(span_start, dependency); + } + } // The Blade preprocessor lowers `@can`/`@cannot`/`@canany` // to this call so the ability string is extracted here // like any other authorization check. @@ -704,6 +728,11 @@ fn extract_call<'a>( }, }); let clean_subject = strip_fqn_prefix(&subject_text); + let semantic_subject = ctx + .resolved_name_at(class_span.start.offset) + .map(strip_fqn_prefix) + .unwrap_or(clean_subject); + let laravel_span_start = ctx.spans.len(); if (clean_subject.eq_ignore_ascii_case("Config") || clean_subject.eq_ignore_ascii_case("Illuminate\\Support\\Facades\\Config")) && is_config_repository_method(&member_name) @@ -715,14 +744,20 @@ fn extract_call<'a>( &mut ctx.spans, ); } - try_emit_laravel_storage_disk_spans( - &subject_text, + if let Some(trigger) = crate::symbol_map::laravel_resources::static_method_trigger( + semantic_subject, &member_name, - &static_call.argument_list, - &mut ctx.laravel_storage_facade_names, - ctx.content, - &mut ctx.spans, - ); + ) { + try_emit_laravel_config_resource_span_for_parameter( + trigger.kind, + trigger.shape, + trigger.access, + &static_call.argument_list, + trigger.argument, + ctx.content, + &mut ctx.spans, + ); + } // The `View` facade proxies the view factory, so every // factory method that names a template does so here too. if clean_subject.eq_ignore_ascii_case("View") @@ -849,11 +884,12 @@ fn extract_call<'a>( // parameter of a route registration. if is_gate_facade(clean_subject) { emit_gate_facade_ability_spans(&member_name, &static_call.argument_list, ctx); - } else if clean_subject.eq_ignore_ascii_case("Route") + } else if matches_laravel_facade(semantic_subject, "Route") && member_name.eq_ignore_ascii_case("middleware") { try_emit_can_middleware_spans( &static_call.argument_list, + true, ctx.content, &mut ctx.spans, ); @@ -900,6 +936,11 @@ fn extract_call<'a>( &mut ctx.spans, ); } + if let Some(dependency) = ctx.resolved_names.and_then(|_| { + crate::symbol_map::LaravelStringDependency::root_facade(semantic_subject) + }) { + ctx.defer_laravel_spans_since(laravel_span_start, dependency); + } } extract_from_arguments(&static_call.argument_list.arguments, ctx, scope_start); } @@ -1010,14 +1051,27 @@ fn emit_gate_ability_spans_for_method<'a>( return; } - if is_middleware && (receiver_is_this || chain_roots_at_route_facade(object)) { - try_emit_can_middleware_spans(argument_list, ctx.content, &mut ctx.spans); + let route_dependency = if is_middleware || is_can { + ctx.resolved_names + .and_then(|names| route_facade_root_dependency(object, names)) + } else { + None + }; + let route_receiver = + (is_middleware || is_can) && chain_roots_at_route_facade(object, ctx.resolved_names); + if is_middleware && (receiver_is_this || route_receiver) { + let span_start = ctx.spans.len(); + try_emit_can_middleware_spans(argument_list, route_receiver, ctx.content, &mut ctx.spans); + if let Some(dependency) = route_dependency { + ctx.defer_laravel_spans_since(span_start, dependency); + } return; } // A route registration's `->can('update', 'post')` names a route // parameter, not a model, in its second argument. - if is_can && chain_roots_at_route_facade(object) { + if is_can && route_receiver { + let span_start = ctx.spans.len(); try_emit_gate_ability_spans( argument_list, 0, @@ -1027,6 +1081,9 @@ fn emit_gate_ability_spans_for_method<'a>( &mut ctx.spans, &mut ctx.gate_subjects, ); + if let Some(dependency) = route_dependency { + ctx.defer_laravel_spans_since(span_start, dependency); + } return; } @@ -1132,3 +1189,27 @@ pub(super) fn extract_partial_application_expr<'a>( } } } + +#[cfg(test)] +mod tests { + use crate::names::OwnedResolvedNames; + use crate::symbol_map::LaravelStringDependency; + + #[test] + fn route_can_span_waits_for_the_root_alias_shadow_check() { + let php = "can('update', 'post');\n"; + let arena = mago_allocator::LocalArena::new(); + let file_id = mago_database::file::FileId::new(b"test.php"); + let program = mago_syntax::parser::parse_file_content(&arena, file_id, php.as_bytes()); + let resolver = mago_names::resolver::NameResolver::new(&arena); + let names = OwnedResolvedNames::from_resolved(&resolver.resolve(program)); + + let map = crate::symbol_map::extraction::extract_symbol_map_for_index(program, php, &names); + + assert_eq!(map.conditional_laravel_spans.len(), 1); + assert_eq!( + Some(map.conditional_laravel_spans[0].dependency), + LaravelStringDependency::root_facade("Route") + ); + } +} diff --git a/src/symbol_map/extraction/laravel.rs b/src/symbol_map/extraction/laravel.rs index 86a2ee50f..d88142d1a 100644 --- a/src/symbol_map/extraction/laravel.rs +++ b/src/symbol_map/extraction/laravel.rs @@ -5,25 +5,33 @@ use super::*; /// Namespace prefix for Laravel's container-injection attributes. pub(super) const LARAVEL_CONTAINER_ATTR_NS: &str = "Illuminate\\Container\\Attributes\\"; -/// Short (non-FQN) names of the container-injection attributes. -pub(super) const LARAVEL_CONTAINER_ATTR_NAMES: &[&str] = &[ - "Config", - "Database", - "DB", - "Cache", - "Log", - "Storage", - "Auth", - "Authenticated", -]; + +/// Whether a semantically resolved class is either Laravel's global runtime +/// alias or the exact class inside `namespace`. +pub(super) fn matches_laravel_class(class_name: &str, namespace: &str, short: &str) -> bool { + let class_name = class_name.trim_start_matches('\\'); + if class_name.eq_ignore_ascii_case(short) { + return true; + } + class_name + .rsplit_once('\\') + .is_some_and(|(actual_namespace, actual_short)| { + actual_namespace.eq_ignore_ascii_case(namespace) + && actual_short.eq_ignore_ascii_case(short) + }) +} + +/// Whether a semantically resolved class names one Laravel facade. +pub(super) fn matches_laravel_facade(class_name: &str, short: &str) -> bool { + matches_laravel_class(class_name, "Illuminate\\Support\\Facades", short) +} /// The Laravel-specific meaning of a container-injection attribute. pub(super) enum LaravelContainerAttribute { - /// A full config key, as accepted by `#[Config]` and the pre-existing - /// generic handling for the other container attributes. + /// A full config key accepted by `#[Config]`. Config, - /// A disk name accepted by `#[Storage]`. - StorageDisk, + /// A named config resource accepted by the matching container attribute. + Resource(crate::symbol_map::laravel_resources::ResourceTriggerMatch), } /// Check whether an attribute class name refers to a Laravel container @@ -36,45 +44,34 @@ pub(super) enum LaravelContainerAttribute { /// `import_cache` to avoid repeated linear scans of the file content. pub(super) fn resolve_laravel_container_attr( class_name: &str, + allow_short_import_heuristic: bool, import_cache: &mut Option, content: &str, ) -> Option { let short = if class_name.contains('\\') { - class_name.strip_prefix(LARAVEL_CONTAINER_ATTR_NS)? + let class_name = class_name.trim_start_matches('\\'); + let prefix = class_name.get(..LARAVEL_CONTAINER_ATTR_NS.len())?; + if !prefix.eq_ignore_ascii_case(LARAVEL_CONTAINER_ATTR_NS) { + return None; + } + class_name.get(LARAVEL_CONTAINER_ATTR_NS.len()..)? } else { - if !LARAVEL_CONTAINER_ATTR_NAMES.contains(&class_name) { + if !allow_short_import_heuristic { return None; } - let has_import = - *import_cache.get_or_insert_with(|| content.contains(LARAVEL_CONTAINER_ATTR_NS)); + let has_import = *import_cache + .get_or_insert_with(|| content.contains("use Illuminate\\Container\\Attributes\\")); if !has_import { return None; } class_name }; - if !LARAVEL_CONTAINER_ATTR_NAMES.contains(&short) { - return None; - } - if short != "Storage" { + + if short.eq_ignore_ascii_case("Config") { return Some(LaravelContainerAttribute::Config); } - // Every other container attribute names a whole config key, so a - // dotless argument from an application's own same-named attribute - // records nothing. `#[Storage]` prepends a subtree instead, turning - // any argument into a well-formed key, so the short spelling has to be - // imported by that exact name before it is read as a disk. - (class_name.contains('\\') || imports_laravel_storage_attribute(content)) - .then_some(LaravelContainerAttribute::StorageDisk) -} - -/// Whether the file imports `#[Storage]` from Laravel's container-attribute -/// namespace under that short name. -fn imports_laravel_storage_attribute(content: &str) -> bool { - crate::text_scan::imports_class_as( - content, - &format!("{LARAVEL_CONTAINER_ATTR_NS}Storage"), - "Storage", - ) + crate::symbol_map::laravel_resources::attribute_trigger(short) + .map(LaravelContainerAttribute::Resource) } /// Namespace prefix for the attributes a form request is configured with. @@ -143,81 +140,54 @@ pub(super) fn try_emit_laravel_string_spans_all( spans: &mut Vec, ) { for argument in argument_list.arguments.iter() { - push_laravel_string_span(kind.clone(), false, false, argument.value(), content, spans); + push_laravel_string_span(kind, false, false, argument.value(), content, spans); } } -const STORAGE_DISK_CONFIG_PREFIX: &str = "filesystems.disks."; - -/// Emit config-key spans for the disk names accepted by Laravel's `Storage` -/// facade. Registration helpers are writes; `forgetDisk()` is an optional -/// read because forgetting an unconfigured disk is valid. -/// -/// `disk`, `fake` and `forgetDisk` are common method names on other facades -/// (`Http::fake()`, `Mail::fake()`, …), so the names the `Storage` facade -/// answers to in this file are resolved once and cached in `facade_names` -/// rather than rescanning the source at every such call. -pub(super) fn try_emit_laravel_storage_disk_spans( - facade: &str, - member_name: &str, +/// Emit a config-backed resource name from a parameter selected by name or +/// positional slot. +pub(super) fn try_emit_laravel_config_resource_span_for_parameter( + resource: crate::symbol_map::LaravelConfigResource, + shape: crate::symbol_map::laravel_resources::ResourceArgumentShape, + access: crate::symbol_map::laravel_resources::ResourceAccess, argument_list: &ArgumentList<'_>, - facade_names: &mut Option>, + parameter: &str, content: &str, spans: &mut Vec, ) { - let (parameter, accepts_array, is_write, is_optional) = - if member_name.eq_ignore_ascii_case("disk") { - ("name", false, false, false) - } else if member_name.eq_ignore_ascii_case("fake") - || member_name.eq_ignore_ascii_case("persistentFake") - { - ("disk", false, true, false) - } else if member_name.eq_ignore_ascii_case("forgetDisk") { - ("disk", true, false, true) - } else { - return; - }; - let facade_names = facade_names.get_or_insert_with(|| { - crate::virtual_members::laravel::storage_facade_local_names(content) - }); - if !crate::virtual_members::laravel::is_storage_facade_name(facade, facade_names) { - return; - } - let Some(argument) = argument_expr_for_parameter(argument_list, parameter) else { return; }; - if accepts_array { - let elements = match argument { - Expression::Array(array) => Some(&array.elements), - Expression::LegacyArray(array) => Some(&array.elements), - _ => None, - }; - if let Some(elements) = elements { - for element in elements.iter() { - let value = match element { - ArrayElement::KeyValue(element) => element.value, - ArrayElement::Value(element) => element.value, - ArrayElement::Variadic(_) | ArrayElement::Missing(_) => continue, - }; - push_storage_disk_span(value, is_write, is_optional, content, spans); - } - return; + let elements = match argument { + Expression::Array(array) if shape.accepts_array() => Some(&array.elements), + Expression::LegacyArray(array) if shape.accepts_array() => Some(&array.elements), + _ => None, + }; + if let Some(elements) = elements { + for element in elements.iter() { + let value = match element { + ArrayElement::KeyValue(element) => element.value, + ArrayElement::Value(element) => element.value, + ArrayElement::Variadic(_) | ArrayElement::Missing(_) => continue, + }; + push_laravel_string_span( + crate::symbol_map::LaravelStringKind::ConfigResource(resource), + access.is_write(), + access.is_optional(), + value, + content, + spans, + ); } - } - push_storage_disk_span(argument, is_write, is_optional, content, spans); -} - -/// Emit the disk name accepted by Laravel's `#[Storage]` container -/// attribute, selecting the `disk` argument even when named arguments are -/// reordered. -pub(super) fn try_emit_laravel_storage_disk_span_partial( - argument_list: &PartialArgumentList<'_>, - content: &str, - spans: &mut Vec, -) { - if let Some(argument) = partial_argument_expr_for_parameter(argument_list, "disk") { - push_storage_disk_span(argument, false, false, content, spans); + } else if shape.accepts_scalar() { + push_laravel_string_span( + crate::symbol_map::LaravelStringKind::ConfigResource(resource), + access.is_write(), + access.is_optional(), + argument, + content, + spans, + ); } } @@ -227,7 +197,7 @@ fn argument_expr_for_parameter<'a>( ) -> Option<&'a Expression<'a>> { for argument in argument_list.arguments.iter() { if let Argument::Named(named) = argument - && bytes_to_str(named.name.value).eq_ignore_ascii_case(parameter) + && bytes_to_str(named.name.value) == parameter { return Some(named.value); } @@ -247,7 +217,7 @@ fn partial_argument_expr_for_parameter<'a>( ) -> Option<&'a Expression<'a>> { for argument in argument_list.arguments.iter() { if let PartialArgument::Named(named) = argument - && bytes_to_str(named.name.value).eq_ignore_ascii_case(parameter) + && bytes_to_str(named.name.value) == parameter { return Some(named.value); } @@ -261,31 +231,6 @@ fn partial_argument_expr_for_parameter<'a>( }) } -fn push_storage_disk_span( - expression: &Expression<'_>, - is_write: bool, - is_optional: bool, - content: &str, - spans: &mut Vec, -) { - let Some((start, end, disk)) = string_literal_content(expression, content) else { - return; - }; - let mut key = String::with_capacity(STORAGE_DISK_CONFIG_PREFIX.len() + disk.len()); - key.push_str(STORAGE_DISK_CONFIG_PREFIX); - key.push_str(disk); - spans.push(SymbolSpan { - start, - end, - kind: SymbolKind::LaravelStringKey { - key, - kind: crate::symbol_map::LaravelStringKind::Config, - is_write, - is_optional, - }, - }); -} - /// The offsets and text of a plain string literal's content, between the /// quotes. An interpolated or concatenated expression, or an empty string, /// names nothing and yields `None`. @@ -481,8 +426,9 @@ fn push_laravel_string_span( return; }; - if kind == crate::symbol_map::LaravelStringKind::Config && !key.contains('.') { - // Require at least one dot: bare keys like 'app' are not valid config paths. + if kind == crate::symbol_map::LaravelStringKind::Config && !is_write && !key.contains('.') { + // A bare read names a config file, not one of its keys. A write can + // establish that whole file's namespace with an opaque runtime value. return; } @@ -506,7 +452,7 @@ fn push_laravel_string_span( start: inner_start, end: inner_end, kind: SymbolKind::LaravelStringKey { - key: normalised_key(kind.clone(), key), + key: normalised_key(kind, key), kind, is_write, is_optional, @@ -541,7 +487,7 @@ pub(super) fn try_emit_laravel_view_span_at( // working as intended rather than a typo. for element in elements.iter() { if let ArrayElement::Value(value) = element { - push_laravel_string_span(kind.clone(), false, true, value.value, content, spans); + push_laravel_string_span(kind, false, true, value.value, content, spans); } } } @@ -1076,20 +1022,14 @@ pub(super) fn is_gate_facade(clean_subject: &str) -> bool { || clean_subject.eq_ignore_ascii_case("Illuminate\\Support\\Facades\\Gate") } -/// How far back down a method chain a facade root is looked for. -/// -/// The chains this recognises are short — `Gate::forUser($user)->allows(…)` is -/// one link, `Route::get(…)->name(…)->middleware(…)` a handful. The bound is -/// what keeps the cost linear: several of the method names that start this -/// search (`has`, `any`, `check`) are also ordinary query-builder methods, and -/// an unbounded walk would rescan the whole spine at every link of a long -/// Eloquent chain. -const FACADE_CHAIN_DEPTH: usize = 4; - /// Whether an instance-method chain roots at the `Gate` facade, as /// `Gate::forUser($user)->allows('update', $post)` does. pub(super) fn chain_roots_at_gate(expr: &Expression<'_>) -> bool { - chain_roots_at_facade(expr, FACADE_CHAIN_DEPTH, &|name| is_gate_facade(name)) + chain_roots_at_facade( + expr, + crate::symbol_map::laravel_resources::FACADE_CHAIN_DEPTH, + &|name| is_gate_facade(name), + ) } /// Whether an instance-method chain roots at the `Route` facade, as @@ -1097,10 +1037,74 @@ pub(super) fn chain_roots_at_gate(expr: &Expression<'_>) -> bool { /// /// The `can()` there names an ability, but its second argument is a *route /// parameter* name rather than a model, so no model subject is recorded. -pub(super) fn chain_roots_at_route_facade(expr: &Expression<'_>) -> bool { - chain_roots_at_facade(expr, FACADE_CHAIN_DEPTH, &|name| { - name.eq_ignore_ascii_case("Route") - }) +pub(super) fn chain_roots_at_route_facade( + expr: &Expression<'_>, + resolved_names: Option<&crate::names::OwnedResolvedNames>, +) -> bool { + chain_roots_at_route_facade_inner( + expr, + crate::symbol_map::laravel_resources::FACADE_CHAIN_DEPTH, + resolved_names, + ) +} + +/// Return the real global class that can suppress a fluent chain rooted at +/// Laravel's optional `Route` alias. Imported/FQN facade roots are +/// unambiguous and therefore have no dependency. +pub(super) fn route_facade_root_dependency( + expr: &Expression<'_>, + resolved_names: &crate::names::OwnedResolvedNames, +) -> Option { + route_facade_root_dependency_inner( + expr, + crate::symbol_map::laravel_resources::FACADE_CHAIN_DEPTH, + resolved_names, + ) +} + +fn route_facade_root_dependency_inner( + expr: &Expression<'_>, + depth: usize, + resolved_names: &crate::names::OwnedResolvedNames, +) -> Option { + if depth == 0 { + return None; + } + match expr { + Expression::Call(Call::Method(call)) => { + route_facade_root_dependency_inner(call.object, depth - 1, resolved_names) + } + Expression::Call(Call::StaticMethod(call)) => { + let name = resolved_names.get(call.class.span().start.offset)?; + crate::symbol_map::LaravelStringDependency::root_facade(name) + } + _ => None, + } +} + +fn chain_roots_at_route_facade_inner( + expr: &Expression<'_>, + depth: usize, + resolved_names: Option<&crate::names::OwnedResolvedNames>, +) -> bool { + if depth == 0 { + return false; + } + match expr { + Expression::Call(Call::Method(call)) => { + chain_roots_at_route_facade_inner(call.object, depth - 1, resolved_names) + } + Expression::Call(Call::StaticMethod(call)) => { + if let Some(names) = resolved_names { + names + .get(call.class.span().start.offset) + .is_some_and(|name| matches_laravel_facade(name, "Route")) + } else { + matches_laravel_facade(strip_fqn_prefix(&expr_to_subject_text(call.class)), "Route") + } + } + _ => false, + } } /// Walk at most `depth` links down a method chain looking for a static call @@ -1294,39 +1298,50 @@ fn gate_model_subject( } } -/// Emit gate-ability spans for the `can:ability,Model` entries of a -/// `middleware(...)` argument, which Laravel accepts as a single string or an -/// array of them. +/// Emit navigable parameters embedded in Laravel middleware strings. +/// +/// Handles `can:ability,Model` and, when `include_auth_guards` is true, +/// `auth:guard[,guard]`, each accepted as one string or as an array value. pub(super) fn try_emit_can_middleware_spans( argument_list: &ArgumentList<'_>, + include_auth_guards: bool, content: &str, spans: &mut Vec, ) { - let Some(first_arg) = argument_list.arguments.iter().next() else { + let Some(middleware) = argument_expr_for_parameter(argument_list, "middleware") else { return; }; - match first_arg.value() { + match middleware { Expression::Array(array) => { for element in array.elements.iter() { - if let ArrayElement::Value(value) = element { - push_can_middleware_span(value.value, content, spans); - } + let value = match element { + ArrayElement::KeyValue(value) => value.value, + ArrayElement::Value(value) => value.value, + ArrayElement::Variadic(_) | ArrayElement::Missing(_) => continue, + }; + push_middleware_parameter_spans(value, include_auth_guards, content, spans); } } Expression::LegacyArray(array) => { for element in array.elements.iter() { - if let ArrayElement::Value(value) = element { - push_can_middleware_span(value.value, content, spans); - } + let value = match element { + ArrayElement::KeyValue(value) => value.value, + ArrayElement::Value(value) => value.value, + ArrayElement::Variadic(_) | ArrayElement::Missing(_) => continue, + }; + push_middleware_parameter_spans(value, include_auth_guards, content, spans); } } - expr => push_can_middleware_span(expr, content, spans), + expr => push_middleware_parameter_spans(expr, include_auth_guards, content, spans), } } -/// Push a gate-ability span covering just the ability part of a -/// `'can:update,post'` middleware string. -fn push_can_middleware_span(expr: &Expression<'_>, content: &str, spans: &mut Vec) { +fn push_middleware_parameter_spans( + expr: &Expression<'_>, + include_auth_guards: bool, + content: &str, + spans: &mut Vec, +) { let Expression::Literal(literal::Literal::String(s)) = expr else { return; }; @@ -1336,20 +1351,52 @@ fn push_can_middleware_span(expr: &Expression<'_>, content: &str, spans: &mut Ve return; } let text = &content[inner_start as usize..inner_end as usize]; - let Some(rest) = text.strip_prefix("can:") else { + let Some((alias, parameters)) = text.split_once(':') else { return; }; - let ability = rest.split(',').next().unwrap_or(rest); - if ability.is_empty() { + let parameter_start = inner_start + alias.len() as u32 + 1; + if alias == "can" { + let ability = parameters.split(',').next().unwrap_or(parameters); + push_embedded_string_span( + crate::symbol_map::LaravelStringKind::GateAbility, + ability, + parameter_start, + spans, + ); + } else if include_auth_guards + && crate::symbol_map::laravel_resources::middleware_resource(&text[..alias.len() + 1]) + == Some(crate::symbol_map::LaravelConfigResource::AuthGuard) + { + let mut offset = parameter_start; + for guard in parameters.split(',') { + push_embedded_string_span( + crate::symbol_map::LaravelStringKind::ConfigResource( + crate::symbol_map::LaravelConfigResource::AuthGuard, + ), + guard, + offset, + spans, + ); + offset += guard.len() as u32 + 1; + } + } +} + +fn push_embedded_string_span( + kind: crate::symbol_map::LaravelStringKind, + key: &str, + start: u32, + spans: &mut Vec, +) { + if key.is_empty() { return; } - let start = inner_start + "can:".len() as u32; spans.push(SymbolSpan { start, - end: start + ability.len() as u32, + end: start + key.len() as u32, kind: SymbolKind::LaravelStringKey { - kind: crate::symbol_map::LaravelStringKind::GateAbility, - key: ability.to_string(), + kind, + key: key.to_string(), is_write: false, is_optional: false, }, @@ -1399,16 +1446,16 @@ pub(super) fn try_emit_command_own_param_span( /// `Config::get()` / `Config::set()` static-call extractor so that /// find-references and go-to-definition for Laravel config keys can use /// the pre-built symbol map instead of re-parsing every file on demand. -pub(super) fn try_emit_laravel_string_span_partial( +pub(super) fn try_emit_laravel_string_span_partial_for_parameter( kind: crate::symbol_map::LaravelStringKind, argument_list: &PartialArgumentList<'_>, + parameter: &str, content: &str, spans: &mut Vec, ) { - let Some(first_arg) = argument_list.arguments.iter().next() else { - return; - }; - let Some(Expression::Literal(literal::Literal::String(s))) = first_arg.value() else { + let Some(Expression::Literal(literal::Literal::String(s))) = + partial_argument_expr_for_parameter(argument_list, parameter) + else { return; }; let inner_start = s.span.start.offset + 1; @@ -1430,7 +1477,7 @@ pub(super) fn try_emit_laravel_string_span_partial( start: inner_start, end: inner_end, kind: SymbolKind::LaravelStringKey { - key: normalised_key(kind.clone(), key), + key: normalised_key(kind, key), kind, is_write: false, is_optional: false, @@ -1438,6 +1485,28 @@ pub(super) fn try_emit_laravel_string_span_partial( }); } +/// Attribute counterpart of named parameter selection for config resources. +pub(super) fn try_emit_laravel_config_resource_span_partial_for_parameter( + resource: crate::symbol_map::LaravelConfigResource, + access: crate::symbol_map::laravel_resources::ResourceAccess, + argument_list: &PartialArgumentList<'_>, + parameter: &str, + content: &str, + spans: &mut Vec, +) { + let Some(expr) = partial_argument_expr_for_parameter(argument_list, parameter) else { + return; + }; + push_laravel_string_span( + crate::symbol_map::LaravelStringKind::ConfigResource(resource), + access.is_write(), + access.is_optional(), + expr, + content, + spans, + ); +} + /// If `argument_list` starts with a plain, non-empty string literal, push a /// [`SymbolKind::LaravelMacroString`] span covering the string content. pub(super) fn try_emit_laravel_macro_string_span( @@ -1919,25 +1988,42 @@ pub(super) fn laravel_route_scan_expr( mod storage_disk_tests { use super::*; + #[test] + fn facade_matching_accepts_only_runtime_aliases_and_laravel_facades() { + assert!(matches_laravel_facade("Storage", "Storage")); + assert!(matches_laravel_facade( + "Illuminate\\Support\\Facades\\Storage", + "Storage" + )); + assert!(!matches_laravel_facade("App\\Storage", "Storage")); + assert!(!matches_laravel_facade("Acme\\Storage", "Storage")); + } + #[test] fn fully_qualified_container_attributes_keep_their_distinct_meanings() { let mut import_cache = None; assert!(matches!( resolve_laravel_container_attr( "Illuminate\\Container\\Attributes\\Storage", + false, &mut import_cache, "" ), - Some(LaravelContainerAttribute::StorageDisk) + Some(LaravelContainerAttribute::Resource(trigger)) + if trigger.kind == crate::symbol_map::LaravelConfigResource::StorageDisk )); assert!(matches!( resolve_laravel_container_attr( "Illuminate\\Container\\Attributes\\Config", + false, &mut import_cache, "" ), Some(LaravelContainerAttribute::Config) )); + assert!( + resolve_laravel_container_attr("App\\Storage", false, &mut import_cache, "").is_none() + ); } #[test] diff --git a/src/symbol_map/extraction/mod.rs b/src/symbol_map/extraction/mod.rs index 7f7de5586..406b46bc8 100644 --- a/src/symbol_map/extraction/mod.rs +++ b/src/symbol_map/extraction/mod.rs @@ -17,6 +17,7 @@ use super::{ ViewReceiverClass, ViewReceiverSite, }; use crate::atom::{bytes_to_str, literal_bytes_to_str}; +use crate::names::OwnedResolvedNames; use crate::util::strip_fqn_prefix; // ─── Extraction context ───────────────────────────────────────────────────── @@ -70,6 +71,13 @@ struct ExtractionCtx<'a> { trivias: &'a [Trivia<'a>], /// The full source text of the file being extracted. content: &'a str, + /// Semantic names resolved by `mago-names` for this file. Production + /// indexing supplies this so Laravel-specific syntax does not guess from + /// an alias or a namespace-local homonym. Syntax-only tests omit it. + resolved_names: Option<&'a OwnedResolvedNames>, + /// Laravel strings whose meaning depends on a workspace symbol being + /// absent. They are activated after the batch publishes its declarations. + conditional_laravel_spans: Vec, /// Closures and arrow functions passed as arguments to callable-typed /// parameters, used by inlay hints. untyped_closure_sites: Vec, @@ -87,11 +95,6 @@ struct ExtractionCtx<'a> { /// Whether the file imports from `Illuminate\Container\Attributes\` /// (checked once lazily, cached for all attribute inspections). has_laravel_container_attrs: Option, - /// The local names Laravel's `Storage` facade answers to in this file, - /// resolved once lazily. `disk()`, `fake()` and `forgetDisk()` are - /// common method names on unrelated facades, so the question comes up - /// often enough that rescanning the source each time is wasteful. - laravel_storage_facade_names: Option>, /// Whether the file imports from `PHPUnit\Framework\Attributes\`, cached /// the same way as [`Self::has_laravel_container_attrs`]. has_phpunit_attrs: Option, @@ -115,6 +118,28 @@ struct ExtractionCtx<'a> { covers_default_class: Option, } +impl<'a> ExtractionCtx<'a> { + fn resolved_name_at(&self, offset: u32) -> Option<&'a str> { + self.resolved_names.and_then(|names| names.get(offset)) + } + + /// Move Laravel string spans emitted since `start` into the dormant + /// candidate list. The tail is normally one span, so removing in place + /// avoids allocating a temporary vector on this already-rare path. + fn defer_laravel_spans_since( + &mut self, + start: usize, + dependency: super::LaravelStringDependency, + ) { + let spans = &mut self.spans; + let candidates = &mut self.conditional_laravel_spans; + for span in spans.drain(start..) { + debug_assert!(matches!(&span.kind, SymbolKind::LaravelStringKey { .. })); + candidates.push(super::ConditionalLaravelStringSpan::new(dependency, span)); + } + } +} + mod class_like; mod expressions; mod keywords; @@ -154,7 +179,38 @@ fn descend_unhandled<'a>(node: Node<'a, 'a>, ctx: &mut ExtractionCtx<'a>, scope_ /// /// Walks every statement recursively and emits [`SymbolSpan`] entries for /// every navigable symbol occurrence. +#[cfg(test)] pub(crate) fn extract_symbol_map(program: &Program<'_>, content: &str) -> SymbolMap { + extract_symbol_map_inner(program, content, None) +} + +/// Build a [`SymbolMap`] using the semantic names produced by `mago-names`. +#[cfg(test)] +pub(crate) fn extract_symbol_map_with_resolved_names( + program: &Program<'_>, + content: &str, + resolved_names: &OwnedResolvedNames, +) -> SymbolMap { + let mut map = extract_symbol_map_inner(program, content, Some(resolved_names)); + map.refresh_conditional_laravel_spans(None, |_| false); + map +} + +/// Build a semantic map while retaining workspace-dependent Laravel spans as +/// dormant candidates for the publication lifecycle to settle. +pub(crate) fn extract_symbol_map_for_index( + program: &Program<'_>, + content: &str, + resolved_names: &OwnedResolvedNames, +) -> SymbolMap { + extract_symbol_map_inner(program, content, Some(resolved_names)) +} + +fn extract_symbol_map_inner( + program: &Program<'_>, + content: &str, + resolved_names: Option<&OwnedResolvedNames>, +) -> SymbolMap { let mut ctx = ExtractionCtx { spans: Vec::new(), var_defs: Vec::new(), @@ -172,13 +228,14 @@ pub(crate) fn extract_symbol_map(program: &Program<'_>, content: &str) -> Symbol instance_method_scopes: Vec::new(), trivias: program.trivia.as_slice(), content, + resolved_names, + conditional_laravel_spans: Vec::new(), untyped_closure_sites: Vec::new(), view_receiver_sites: Vec::new(), gate_subjects: Vec::new(), cond_nesting_depth: 0, cond_block_end_stack: Vec::new(), has_laravel_container_attrs: None, - laravel_storage_facade_names: None, has_phpunit_attrs: None, has_laravel_http_attrs: None, in_console_command: false, @@ -286,6 +343,7 @@ pub(crate) fn extract_symbol_map(program: &Program<'_>, content: &str) -> Symbol SymbolMap { spans: ctx.spans, + conditional_laravel_spans: ctx.conditional_laravel_spans, member_access_indices, var_defs: ctx.var_defs, scopes: ctx.scopes, diff --git a/src/symbol_map/laravel_resources.rs b/src/symbol_map/laravel_resources.rs new file mode 100644 index 000000000..1aeddc763 --- /dev/null +++ b/src/symbol_map/laravel_resources.rs @@ -0,0 +1,929 @@ +//! Declarative Laravel config-resource families and their string triggers. + +use super::LaravelConfigResource; +use crate::names::OwnedResolvedNames; + +/// Maximum instance-method links inspected before a facade root is considered +/// too far away. Shared by AST extraction and the live completion scanner so +/// the two paths classify the same receiver spines. +pub(crate) const FACADE_CHAIN_DEPTH: usize = 4; + +/// Short facade names accepted by direct config-resource triggers. Kept as a +/// compact static slice so live completion can resolve imports without +/// rebuilding the descriptor-derived set on every keystroke. +pub(crate) const RESOURCE_FACADES: &[&str] = &[ + "Auth", + "Broadcast", + "Cache", + "DB", + "Log", + "Mail", + "Queue", + "Storage", +]; + +/// Container attributes that select a config-backed resource. +pub(crate) const RESOURCE_ATTRIBUTES: &[&str] = &[ + "Auth", + "Authenticated", + "Cache", + "DB", + "Database", + "Log", + "Storage", +]; + +/// How a trigger accepts resource names in its selected argument. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ResourceArgumentShape { + /// One scalar string literal. + Scalar, + /// A scalar string or the string values of an array. + ScalarOrArray, + /// Only the string values of an array. + Array, +} + +impl ResourceArgumentShape { + /// Whether the trigger accepts an array of names. + pub(crate) const fn accepts_array(self) -> bool { + matches!(self, Self::ScalarOrArray | Self::Array) + } + + /// Whether the trigger accepts one scalar name. + pub(crate) const fn accepts_scalar(self) -> bool { + matches!(self, Self::Scalar | Self::ScalarOrArray) + } +} + +/// Whether a resource-name occurrence reads, defines, or optionally removes +/// the resource it names. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ResourceAccess { + Read, + Write, + OptionalRead, +} + +impl ResourceAccess { + pub(crate) const fn is_write(self) -> bool { + matches!(self, Self::Write) + } + + pub(crate) const fn is_optional(self) -> bool { + matches!(self, Self::OptionalRead) + } +} + +/// One syntactic place that accepts a member of a config-resource family. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ConfigResourceTrigger { + Function { + name: &'static str, + argument: &'static str, + shape: ResourceArgumentShape, + access: ResourceAccess, + }, + StaticMethod { + facade: &'static str, + method: &'static str, + argument: &'static str, + shape: ResourceArgumentShape, + access: ResourceAccess, + }, + Attribute { + name: &'static str, + argument: &'static str, + }, + Middleware { + prefix: &'static str, + }, +} + +/// One named-resource family. The table is authoritative metadata; compact +/// lookup matches below mirror its names to keep request-time dispatch O(1). +/// Exhaustive tests fail if the two representations drift. +#[derive(Debug)] +pub(crate) struct ConfigResourceDescriptor { + pub kind: LaravelConfigResource, + pub config_prefix: &'static str, + pub label: &'static str, + pub hover_label: &'static str, + pub diagnostic_code: &'static str, + pub triggers: &'static [ConfigResourceTrigger], +} + +/// The semantic payload shared by completion and symbol extraction after a +/// declarative trigger matches. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct ResourceTriggerMatch { + pub kind: LaravelConfigResource, + pub argument: &'static str, + pub shape: ResourceArgumentShape, + pub access: ResourceAccess, +} + +use ConfigResourceTrigger::{Attribute, Function, Middleware, StaticMethod}; +use ResourceAccess::{OptionalRead, Read, Write}; +use ResourceArgumentShape::{Array, Scalar, ScalarOrArray}; + +/// Every direct, config-backed Laravel string family PHPantom understands. +pub(crate) const CONFIG_RESOURCES: &[ConfigResourceDescriptor] = &[ + ConfigResourceDescriptor { + kind: LaravelConfigResource::AuthGuard, + config_prefix: "auth.guards.", + label: "auth guard", + hover_label: "Auth guard", + diagnostic_code: "invalid_laravel_auth_guard", + triggers: &[ + Function { + name: "auth", + argument: "guard", + shape: Scalar, + access: Read, + }, + StaticMethod { + facade: "Auth", + method: "guard", + argument: "name", + shape: Scalar, + access: Read, + }, + Attribute { + name: "Auth", + argument: "guard", + }, + Attribute { + name: "Authenticated", + argument: "guard", + }, + Middleware { prefix: "auth:" }, + ], + }, + ConfigResourceDescriptor { + kind: LaravelConfigResource::CacheStore, + config_prefix: "cache.stores.", + label: "cache store", + hover_label: "Cache store", + diagnostic_code: "invalid_laravel_cache_store", + triggers: &[ + StaticMethod { + facade: "Cache", + method: "store", + argument: "name", + shape: Scalar, + access: Read, + }, + Attribute { + name: "Cache", + argument: "store", + }, + ], + }, + ConfigResourceDescriptor { + kind: LaravelConfigResource::LogChannel, + config_prefix: "logging.channels.", + label: "log channel", + hover_label: "Log channel", + diagnostic_code: "invalid_laravel_log_channel", + triggers: &[ + StaticMethod { + facade: "Log", + method: "channel", + argument: "channel", + shape: Scalar, + access: Read, + }, + StaticMethod { + facade: "Log", + method: "stack", + argument: "channels", + shape: Array, + access: Read, + }, + Attribute { + name: "Log", + argument: "channel", + }, + ], + }, + ConfigResourceDescriptor { + kind: LaravelConfigResource::StorageDisk, + config_prefix: "filesystems.disks.", + label: "storage disk", + hover_label: "Storage disk", + diagnostic_code: "invalid_laravel_storage_disk", + triggers: &[ + StaticMethod { + facade: "Storage", + method: "disk", + argument: "name", + shape: Scalar, + access: Read, + }, + StaticMethod { + facade: "Storage", + method: "fake", + argument: "disk", + shape: Scalar, + access: Write, + }, + StaticMethod { + facade: "Storage", + method: "persistentFake", + argument: "disk", + shape: Scalar, + access: Write, + }, + StaticMethod { + facade: "Storage", + method: "forgetDisk", + argument: "disk", + shape: ScalarOrArray, + access: OptionalRead, + }, + Attribute { + name: "Storage", + argument: "disk", + }, + ], + }, + ConfigResourceDescriptor { + kind: LaravelConfigResource::DatabaseConnection, + config_prefix: "database.connections.", + label: "database connection", + hover_label: "Database connection", + diagnostic_code: "invalid_laravel_database_connection", + triggers: &[ + StaticMethod { + facade: "DB", + method: "connection", + argument: "name", + shape: Scalar, + access: Read, + }, + Attribute { + name: "Database", + argument: "connection", + }, + Attribute { + name: "DB", + argument: "connection", + }, + ], + }, + ConfigResourceDescriptor { + kind: LaravelConfigResource::QueueConnection, + config_prefix: "queue.connections.", + label: "queue connection", + hover_label: "Queue connection", + diagnostic_code: "invalid_laravel_queue_connection", + triggers: &[StaticMethod { + facade: "Queue", + method: "connection", + argument: "name", + shape: Scalar, + access: Read, + }], + }, + ConfigResourceDescriptor { + kind: LaravelConfigResource::Mailer, + config_prefix: "mail.mailers.", + label: "mailer", + hover_label: "Mailer", + diagnostic_code: "invalid_laravel_mailer", + triggers: &[StaticMethod { + facade: "Mail", + method: "mailer", + argument: "name", + shape: Scalar, + access: Read, + }], + }, + ConfigResourceDescriptor { + kind: LaravelConfigResource::BroadcastConnection, + config_prefix: "broadcasting.connections.", + label: "broadcast connection", + hover_label: "Broadcast connection", + diagnostic_code: "invalid_laravel_broadcast_connection", + triggers: &[StaticMethod { + facade: "Broadcast", + method: "connection", + argument: "name", + shape: Scalar, + access: Read, + }], + }, +]; + +pub(crate) fn descriptor(kind: LaravelConfigResource) -> &'static ConfigResourceDescriptor { + match kind { + LaravelConfigResource::AuthGuard => &CONFIG_RESOURCES[0], + LaravelConfigResource::CacheStore => &CONFIG_RESOURCES[1], + LaravelConfigResource::LogChannel => &CONFIG_RESOURCES[2], + LaravelConfigResource::StorageDisk => &CONFIG_RESOURCES[3], + LaravelConfigResource::DatabaseConnection => &CONFIG_RESOURCES[4], + LaravelConfigResource::QueueConnection => &CONFIG_RESOURCES[5], + LaravelConfigResource::Mailer => &CONFIG_RESOURCES[6], + LaravelConfigResource::BroadcastConnection => &CONFIG_RESOURCES[7], + } +} + +/// Build the dot key used by Laravel's config index for one short resource +/// name. This allocation is paid only at a config boundary, never per stored +/// symbol span. +pub(crate) fn config_key(kind: LaravelConfigResource, short_name: &str) -> String { + let prefix = descriptor(kind).config_prefix; + let child = configured_child_name(kind, short_name); + let mut key = String::with_capacity(prefix.len() + child.len()); + key.push_str(prefix); + key.push_str(child); + key +} + +/// The config-array child selected by a source spelling. +/// +/// Laravel database connections accept a role suffix while still reading the +/// base connection's configuration (`mysql::read`, `::write`, or `::direct`). +pub(crate) fn configured_child_name(kind: LaravelConfigResource, source_name: &str) -> &str { + if kind == LaravelConfigResource::DatabaseConnection { + for suffix in DATABASE_ROLE_SUFFIXES { + if let Some(base) = source_name.strip_suffix(suffix) { + return base; + } + } + } + source_name +} + +/// Database connection role suffixes recognized by Laravel's manager. +pub(crate) const DATABASE_ROLE_SUFFIXES: &[&str] = &["::read", "::write", "::direct"]; + +/// Runtime-provided names that are valid without a matching config child. +pub(crate) fn is_implicit_resource_name(kind: LaravelConfigResource, name: &str) -> bool { + name == "null" + && matches!( + kind, + LaravelConfigResource::CacheStore | LaravelConfigResource::QueueConnection + ) +} + +/// Whether two direct spellings select the same configured resource. +pub(crate) fn same_resource_name(kind: LaravelConfigResource, left: &str, right: &str) -> bool { + configured_child_name(kind, left) == configured_child_name(kind, right) +} + +/// Whether `full_key` is the config address of `short_name` in `kind`. +pub(crate) fn matches_config_key( + kind: LaravelConfigResource, + short_name: &str, + full_key: &str, +) -> bool { + if is_implicit_resource_name(kind, short_name) { + return false; + } + full_key + .strip_prefix(descriptor(kind).config_prefix) + .is_some_and(|rest| rest == configured_child_name(kind, short_name)) +} + +/// Interpret a generic config key as a direct resource child. +pub(crate) fn resource_from_config_key(full_key: &str) -> Option<(LaravelConfigResource, &str)> { + let root = full_key.split_once('.')?.0; + let kind = config_root_resource(root)?; + let short = full_key.strip_prefix(descriptor(kind).config_prefix)?; + (!short.is_empty() && !short.contains('.') && !is_implicit_resource_name(kind, short)) + .then_some((kind, short)) +} + +pub(crate) fn function_trigger(name: &str) -> Option { + trigger_match( + descriptor(LaravelConfigResource::AuthGuard), + |trigger| match trigger { + Function { + name: expected, + argument, + shape, + access, + } if name.eq_ignore_ascii_case(expected) => Some((*argument, *shape, *access)), + _ => None, + }, + ) +} + +/// Cheap method-name prefilter for the live completion path. +pub(crate) fn static_method_may_trigger(method: &str) -> bool { + match method.len() { + 4 => method.eq_ignore_ascii_case("disk") || method.eq_ignore_ascii_case("fake"), + 5 => { + method.eq_ignore_ascii_case("guard") + || method.eq_ignore_ascii_case("store") + || method.eq_ignore_ascii_case("stack") + } + 6 => method.eq_ignore_ascii_case("mailer"), + 7 => method.eq_ignore_ascii_case("channel"), + 10 => { + method.eq_ignore_ascii_case("connection") || method.eq_ignore_ascii_case("forgetDisk") + } + 14 => method.eq_ignore_ascii_case("persistentFake"), + _ => false, + } +} + +/// Resolve a written function call to Laravel's global `auth()` helper. +/// +/// PHP falls back to a global function only when the current namespace has no +/// same-named function. Semantic names settle aliases and same-file shadows; +/// the optional index membership check handles a shadow declared elsewhere. +pub(crate) fn auth_helper_trigger( + content: &str, + written_name: &str, + offset: u32, + resolved_names: Option<&OwnedResolvedNames>, + indexed_function_exists: Option<&dyn Fn(&str) -> bool>, +) -> Option { + if resolved_names + .and_then(|names| names.get(offset)) + .is_some_and(|resolved| resolved.eq_ignore_ascii_case("auth")) + { + return function_trigger("auth"); + } + if !written_name.eq_ignore_ascii_case("auth") { + return None; + } + function_trigger(written_name).filter(|_| { + matches_laravel_auth_helper(content, offset, resolved_names, indexed_function_exists) + }) +} + +fn matches_laravel_auth_helper( + content: &str, + offset: u32, + resolved_names: Option<&OwnedResolvedNames>, + indexed_function_exists: Option<&dyn Fn(&str) -> bool>, +) -> bool { + let Some(names) = resolved_names else { + return true; + }; + let Some(resolved) = names.get(offset) else { + return false; + }; + if resolved.eq_ignore_ascii_case("auth") { + return true; + } + if names.is_imported(offset) { + return false; + } + if names.iter().any(|(declaration_offset, name, _)| { + name.eq_ignore_ascii_case(resolved) + && is_named_function_declaration(content, declaration_offset) + }) { + return false; + } + !indexed_function_exists.is_some_and(|exists| exists(resolved)) +} + +fn is_named_function_declaration(content: &str, offset: u32) -> bool { + let Some(before_name) = content.as_bytes().get(..offset as usize) else { + return false; + }; + let mut end = skip_php_trivia_backwards(before_name, before_name.len()); + if end > 0 && before_name[end - 1] == b'&' { + end = skip_php_trivia_backwards(before_name, end - 1); + } + let start = end.saturating_sub("function".len()); + before_name[start..end].eq_ignore_ascii_case(b"function") + && (start == 0 + || !(before_name[start - 1].is_ascii_alphanumeric() || before_name[start - 1] == b'_')) +} + +fn skip_php_trivia_backwards(bytes: &[u8], mut end: usize) -> usize { + loop { + while end > 0 && bytes[end - 1].is_ascii_whitespace() { + end -= 1; + } + + if end >= 2 + && &bytes[end - 2..end] == b"*/" + && let Some(start) = bytes[..end - 2].windows(2).rposition(|pair| pair == b"/*") + { + end = start; + continue; + } + + let line_start = bytes[..end] + .iter() + .rposition(|byte| *byte == b'\n' || *byte == b'\r') + .map_or(0, |index| index + 1); + let line = &bytes[line_start..end]; + if let Some(comment) = line.windows(2).rposition(|pair| pair == b"//") { + end = line_start + comment; + continue; + } + if let Some(comment) = line.iter().rposition(|byte| *byte == b'#') { + end = line_start + comment; + continue; + } + + return end; + } +} + +pub(crate) fn static_method_trigger(receiver: &str, method: &str) -> Option { + let receiver = receiver.trim_start_matches('\\'); + let short = if let Some((namespace, short)) = receiver.rsplit_once('\\') { + if !namespace.eq_ignore_ascii_case("Illuminate\\Support\\Facades") { + return None; + } + short + } else { + receiver + }; + let resource = descriptor(static_facade_resource(short)?); + trigger_match(resource, |trigger| match trigger { + StaticMethod { + facade, + method: expected, + argument, + shape, + access, + } if short.eq_ignore_ascii_case(facade) && method.eq_ignore_ascii_case(expected) => { + Some((*argument, *shape, *access)) + } + _ => None, + }) +} + +pub(crate) fn attribute_trigger(name: &str) -> Option { + let name = name.trim_start_matches('\\'); + let short = if let Some((namespace, short)) = name.rsplit_once('\\') { + if !namespace.eq_ignore_ascii_case("Illuminate\\Container\\Attributes") { + return None; + } + short + } else { + name + }; + let resource = descriptor(attribute_resource(short)?); + trigger_match(resource, |trigger| match trigger { + Attribute { + name: expected, + argument, + } if short.eq_ignore_ascii_case(expected) => Some(( + *argument, + ResourceArgumentShape::Scalar, + ResourceAccess::Read, + )), + _ => None, + }) +} + +pub(crate) fn middleware_resource(prefix: &str) -> Option { + if prefix != "auth:" { + return None; + } + let resource = descriptor(LaravelConfigResource::AuthGuard); + resource.triggers.iter().find_map(|trigger| match trigger { + Middleware { prefix: expected } if prefix == *expected => Some(resource.kind), + _ => None, + }) +} + +fn trigger_match( + resource: &ConfigResourceDescriptor, + mut select: impl FnMut( + &ConfigResourceTrigger, + ) -> Option<(&'static str, ResourceArgumentShape, ResourceAccess)>, +) -> Option { + resource.triggers.iter().find_map(|trigger| { + let (argument, shape, access) = select(trigger)?; + Some(ResourceTriggerMatch { + kind: resource.kind, + argument, + shape, + access, + }) + }) +} + +fn config_root_resource(root: &str) -> Option { + use LaravelConfigResource::*; + match root.len() { + 4 if root == "auth" => Some(AuthGuard), + 4 if root == "mail" => Some(Mailer), + 5 if root == "cache" => Some(CacheStore), + 5 if root == "queue" => Some(QueueConnection), + 7 if root == "logging" => Some(LogChannel), + 8 if root == "database" => Some(DatabaseConnection), + 11 if root == "filesystems" => Some(StorageDisk), + 12 if root == "broadcasting" => Some(BroadcastConnection), + _ => None, + } +} + +fn static_facade_resource(name: &str) -> Option { + use LaravelConfigResource::*; + match name.len() { + 2 if name.eq_ignore_ascii_case("DB") => Some(DatabaseConnection), + 3 if name.eq_ignore_ascii_case("Log") => Some(LogChannel), + 4 if name.eq_ignore_ascii_case("Auth") => Some(AuthGuard), + 4 if name.eq_ignore_ascii_case("Mail") => Some(Mailer), + 5 if name.eq_ignore_ascii_case("Cache") => Some(CacheStore), + 5 if name.eq_ignore_ascii_case("Queue") => Some(QueueConnection), + 7 if name.eq_ignore_ascii_case("Storage") => Some(StorageDisk), + 9 if name.eq_ignore_ascii_case("Broadcast") => Some(BroadcastConnection), + _ => None, + } +} + +fn attribute_resource(name: &str) -> Option { + use LaravelConfigResource::*; + match name.len() { + 2 if name.eq_ignore_ascii_case("DB") => Some(DatabaseConnection), + 3 if name.eq_ignore_ascii_case("Log") => Some(LogChannel), + 4 if name.eq_ignore_ascii_case("Auth") => Some(AuthGuard), + 5 if name.eq_ignore_ascii_case("Cache") => Some(CacheStore), + 7 if name.eq_ignore_ascii_case("Storage") => Some(StorageDisk), + 8 if name.eq_ignore_ascii_case("Database") => Some(DatabaseConnection), + 13 if name.eq_ignore_ascii_case("Authenticated") => Some(AuthGuard), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn resolved_names(content: &str) -> OwnedResolvedNames { + let arena = mago_allocator::LocalArena::new(); + let file_id = mago_database::file::FileId::new(b"test.php"); + let program = mago_syntax::parser::parse_file_content(&arena, file_id, content.as_bytes()); + let resolver = mago_names::resolver::NameResolver::new(&arena); + OwnedResolvedNames::from_resolved(&resolver.resolve(program)) + } + + #[test] + fn every_resource_has_one_unique_prefix_and_descriptor() { + let mut family_bits = 0_u8; + for (index, resource) in CONFIG_RESOURCES.iter().enumerate() { + assert!(resource.config_prefix.ends_with('.')); + assert_eq!(descriptor(resource.kind).kind, resource.kind); + assert!(CONFIG_RESOURCES[..index].iter().all(|seen| { + seen.kind != resource.kind && seen.config_prefix != resource.config_prefix + })); + assert_eq!(family_bits & resource.kind.bit(), 0); + family_bits |= resource.kind.bit(); + } + assert_eq!(family_bits, u8::MAX); + } + + #[test] + fn every_declarative_trigger_is_reachable_through_its_fast_lookup() { + for resource in CONFIG_RESOURCES { + let root = resource.config_prefix.split('.').next().unwrap(); + assert_eq!(config_root_resource(root), Some(resource.kind)); + for trigger in resource.triggers { + let (found, expected) = match trigger { + Function { + name, + argument, + shape, + access, + } => ( + function_trigger(name), + ResourceTriggerMatch { + kind: resource.kind, + argument, + shape: *shape, + access: *access, + }, + ), + StaticMethod { + facade, + method, + argument, + shape, + access, + } => { + assert!( + RESOURCE_FACADES + .iter() + .any(|candidate| candidate.eq_ignore_ascii_case(facade)) + ); + assert!(static_method_may_trigger(method)); + let expected = ResourceTriggerMatch { + kind: resource.kind, + argument, + shape: *shape, + access: *access, + }; + let short = static_method_trigger(facade, method); + let fqn = format!("Illuminate\\Support\\Facades\\{facade}"); + assert_eq!(static_method_trigger(&fqn, method), Some(expected)); + (short, expected) + } + Attribute { name, argument } => { + assert!( + RESOURCE_ATTRIBUTES + .iter() + .any(|candidate| candidate.eq_ignore_ascii_case(name)) + ); + ( + attribute_trigger(name), + ResourceTriggerMatch { + kind: resource.kind, + argument, + shape: ResourceArgumentShape::Scalar, + access: ResourceAccess::Read, + }, + ) + } + Middleware { prefix } => { + assert_eq!(middleware_resource(prefix), Some(resource.kind)); + continue; + } + }; + assert_eq!(found, Some(expected)); + } + } + } + + #[test] + fn trigger_metadata_preserves_argument_shapes_and_access_modes() { + for (method, access) in [ + ("disk", ResourceAccess::Read), + ("fake", ResourceAccess::Write), + ("persistentFake", ResourceAccess::Write), + ("forgetDisk", ResourceAccess::OptionalRead), + ] { + assert_eq!( + static_method_trigger("Storage", method).map(|found| found.access), + Some(access) + ); + } + assert_eq!( + static_method_trigger("Log", "stack").map(|found| found.shape), + Some(ResourceArgumentShape::Array) + ); + assert_eq!( + static_method_trigger("Storage", "forgetDisk").map(|found| found.shape), + Some(ResourceArgumentShape::ScalarOrArray) + ); + } + + #[test] + fn trigger_lookups_are_case_insensitive_and_context_specific() { + assert_eq!( + function_trigger("AUTH").map(|found| found.kind), + Some(LaravelConfigResource::AuthGuard) + ); + assert_eq!( + static_method_trigger("ILLUMINATE\\SUPPORT\\FACADES\\CACHE", "STORE") + .map(|found| found.kind), + Some(LaravelConfigResource::CacheStore) + ); + assert_eq!( + static_method_trigger("\\Illuminate\\Support\\Facades\\DB", "connection") + .map(|found| found.kind), + Some(LaravelConfigResource::DatabaseConnection) + ); + assert_eq!( + attribute_trigger("ILLUMINATE\\CONTAINER\\ATTRIBUTES\\AUTHENTICATED") + .map(|found| found.kind), + Some(LaravelConfigResource::AuthGuard) + ); + assert_eq!( + attribute_trigger("\\Illuminate\\Container\\Attributes\\Log").map(|found| found.kind), + Some(LaravelConfigResource::LogChannel) + ); + assert!(middleware_resource("AUTH:").is_none()); + assert!(function_trigger("guard").is_none()); + assert!(static_method_trigger("Queue", "mailer").is_none()); + assert!(static_method_trigger("Acme\\Log", "stack").is_none()); + assert!(attribute_trigger("Acme\\Cache").is_none()); + assert!(attribute_trigger("Unknown").is_none()); + assert!(middleware_resource("throttle:").is_none()); + } + + #[test] + fn auth_helper_resolution_honours_aliases_and_namespace_shadows() { + let global = " u8 { + match self { + Self::AuthGuard => 1 << 0, + Self::CacheStore => 1 << 1, + Self::LogChannel => 1 << 2, + Self::StorageDisk => 1 << 3, + Self::DatabaseConnection => 1 << 4, + Self::QueueConnection => 1 << 5, + Self::Mailer => 1 << 6, + Self::BroadcastConnection => 1 << 7, + } + } +} + /// Identifies the category of a [`SymbolKind::LaravelStringKey`] span. /// /// Adding a new Laravel navigation feature only requires adding a variant /// here and updating the extraction and dispatch paths — the exhaustive /// match arms in `highlight`, `hover`, `rename`, `semantic_tokens`, and /// `type_definition` do not need to change. -#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub(crate) enum LaravelStringKind { /// A `config('dot.key')` or `Config::get('dot.key')` call. Config, + /// A short name whose declaration is a direct child of a known config + /// subtree, such as a storage disk or database connection. + ConfigResource(LaravelConfigResource), /// A `view('name')` or `View::make('name')` call. View, /// A `route('name')` call. @@ -494,6 +537,104 @@ pub(crate) enum LaravelStringKind { Env, } +impl LaravelStringKind { + /// Whether this key resolves through Laravel's config index. + pub(crate) const fn is_config_backed(self) -> bool { + matches!(self, Self::Config | Self::ConfigResource(_)) + } +} + +/// A workspace symbol whose presence suppresses an otherwise-valid Laravel +/// runtime fallback. +/// +/// PHP first looks for a namespace-local function before falling back to a +/// global helper, and Laravel's root facade aliases only exist when no real +/// global class has claimed the same name. These dependencies are stored on +/// the handful of ambiguous string spans so edits to another file can toggle +/// the spans without reparsing either file or adding work to request paths. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub(crate) enum LaravelStringDependency { + Function(Atom), + Class(Atom), +} + +impl LaravelStringDependency { + /// The namespace-local `auth` function that may prevent PHP from reaching + /// Laravel's global helper. + pub(crate) fn namespaced_auth(name: &str) -> Option { + let (namespace, short) = name.rsplit_once('\\')?; + (!namespace.is_empty() && short.eq_ignore_ascii_case("auth")) + .then(|| Self::Function(crate::atom::ascii_lowercase_atom(name))) + } + + /// A global class name Laravel may otherwise provide as a runtime facade + /// alias. Fully-qualified facade classes are unambiguous and return none. + pub(crate) fn root_facade(name: &str) -> Option { + let name = name.trim_start_matches('\\'); + if name.contains('\\') { + return None; + } + let is_alias = crate::symbol_map::laravel_resources::RESOURCE_FACADES + .iter() + .any(|alias| name.eq_ignore_ascii_case(alias)) + || match name.len() { + 3 => name.eq_ignore_ascii_case("App"), + 4 => { + name.eq_ignore_ascii_case("Gate") + || name.eq_ignore_ascii_case("Lang") + || name.eq_ignore_ascii_case("View") + } + 5 => name.eq_ignore_ascii_case("Route"), + 6 => name.eq_ignore_ascii_case("Config"), + 7 => name.eq_ignore_ascii_case("Artisan"), + 8 => name.eq_ignore_ascii_case("Response") || name.eq_ignore_ascii_case("Schedule"), + _ => false, + }; + is_alias.then(|| Self::Class(crate::atom::ascii_lowercase_atom(name))) + } +} + +/// A Laravel string span whose meaning depends on a workspace symbol being +/// absent. The payload moves between this side table and [`SymbolMap::spans`] +/// as it becomes dormant/active, so an active candidate does not duplicate +/// its key string in memory. +#[derive(Debug, Clone)] +pub(crate) struct ConditionalLaravelStringSpan { + pub dependency: LaravelStringDependency, + start: u32, + end: u32, + dormant_span: Option, +} + +impl ConditionalLaravelStringSpan { + pub(crate) fn new(dependency: LaravelStringDependency, span: SymbolSpan) -> Self { + Self { + dependency, + start: span.start, + end: span.end, + dormant_span: Some(span), + } + } + + fn is_inserted(&self) -> bool { + self.dormant_span.is_none() + } + + /// Heap bytes owned by a dormant payload for the optional memory audit. + #[cfg(feature = "mem-audit")] + pub(crate) fn audit_heap(&self) -> usize { + self.dormant_span + .as_ref() + .map_or(0, |span| span.kind.audit_heap()) + } +} + +fn is_candidate_span(span: &SymbolSpan, candidate: &ConditionalLaravelStringSpan) -> bool { + span.start == candidate.start + && span.end == candidate.end + && matches!(&span.kind, SymbolKind::LaravelStringKey { .. }) +} + /// The model a gate check names, recorded alongside its ability span. /// /// Kept in a side table rather than inside [`SymbolKind::GateAbility`] so @@ -788,6 +929,9 @@ pub(crate) enum VarDefKind { #[derive(Debug, Clone, Default)] pub(crate) struct SymbolMap { pub spans: Vec, + /// Laravel string spans gated by cross-file function/class membership. + /// Empty for ordinary PHP files and never consulted by request handlers. + pub conditional_laravel_spans: Vec, /// Member-access span indices keyed by member name. /// /// This lets references/rename jump straight to relevant `->name` / @@ -892,6 +1036,85 @@ pub(crate) struct SymbolMap { } impl SymbolMap { + /// Whether this map has a dormant span gated by one of `affected`. + pub(crate) fn has_conditional_laravel_dependency( + &self, + affected: Option<&std::collections::HashSet>, + ) -> bool { + self.conditional_laravel_spans.iter().any(|candidate| { + affected.is_none_or(|dependencies| dependencies.contains(&candidate.dependency)) + }) + } + + /// Whether reconciling the selected dependencies would add or remove at + /// least one span. This lets the publication path avoid cloning an + /// already-correct map merely to discover that it is unchanged. + pub(crate) fn conditional_laravel_spans_need_refresh( + &self, + affected: Option<&std::collections::HashSet>, + mut dependency_exists: impl FnMut(LaravelStringDependency) -> bool, + ) -> bool { + self.conditional_laravel_spans.iter().any(|candidate| { + affected.is_none_or(|dependencies| dependencies.contains(&candidate.dependency)) + && candidate.is_inserted() == dependency_exists(candidate.dependency) + }) + } + + /// Reconcile dormant Laravel spans against the current workspace symbol + /// indexes. When `affected` is supplied, candidates with any other + /// dependency are skipped, keeping cross-file edits proportional to the + /// uncommon ambiguous call sites rather than to every span in a file. + pub(crate) fn refresh_conditional_laravel_spans( + &mut self, + affected: Option<&std::collections::HashSet>, + mut dependency_exists: impl FnMut(LaravelStringDependency) -> bool, + ) -> bool { + let mut changed = false; + let mut added = false; + for candidate in &mut self.conditional_laravel_spans { + if affected.is_some_and(|set| !set.contains(&candidate.dependency)) { + continue; + } + let should_insert = !dependency_exists(candidate.dependency); + if should_insert == candidate.is_inserted() { + continue; + } + + if should_insert { + self.spans.push( + candidate + .dormant_span + .take() + .expect("a dormant candidate must own its span"), + ); + added = true; + } else { + let existing = self + .spans + .iter() + .position(|span| is_candidate_span(span, candidate)) + .expect("an active candidate must be present in the symbol map"); + candidate.dormant_span = Some(self.spans.remove(existing)); + } + changed = true; + } + if added { + self.spans.sort_by_key(|span| span.start); + } + if changed { + self.member_access_indices.clear(); + for (index, span) in self.spans.iter().enumerate() { + if let SymbolKind::MemberAccess { member_name, .. } = &span.kind { + self.member_access_indices + .entry(*member_name) + .or_default() + .push(index); + } + } + } + changed + } + /// Whether this map's offsets are valid indices into `content`. /// /// A length match means no text was inserted or removed since the map diff --git a/src/symbol_map/tests.rs b/src/symbol_map/tests.rs index 3ae0dab99..460b952c9 100644 --- a/src/symbol_map/tests.rs +++ b/src/symbol_map/tests.rs @@ -1,5 +1,5 @@ use super::docblock::is_navigable_type; -use super::extraction::extract_symbol_map; +use super::extraction::{extract_symbol_map, extract_symbol_map_with_resolved_names}; use super::*; // ── SymbolMap::lookup tests ───────────────────────────────────────── @@ -137,6 +137,16 @@ fn parse_and_extract(php: &str) -> SymbolMap { extract_symbol_map(program, php) } +fn parse_and_extract_semantic(php: &str) -> SymbolMap { + let arena = mago_allocator::LocalArena::new(); + let file_id = mago_database::file::FileId::new(b"test.php"); + let program = mago_syntax::parser::parse_file_content(&arena, file_id, php.as_bytes()); + let resolver = mago_names::resolver::NameResolver::new(&arena); + let resolved = resolver.resolve(program); + let owned = crate::names::OwnedResolvedNames::from_resolved(&resolved); + extract_symbol_map_with_resolved_names(program, php, &owned) +} + #[test] fn class_declaration_produces_class_declaration() { let php = " Vec<(String, bool, bool)> { .iter() .filter_map(|span| match &span.kind { SymbolKind::LaravelStringKey { - kind: LaravelStringKind::Config, + kind: LaravelStringKind::ConfigResource(LaravelConfigResource::StorageDisk), key, is_write, is_optional, - } if key.starts_with("filesystems.disks.") => { - Some((key.clone(), *is_write, *is_optional)) - } + } => Some(( + crate::symbol_map::laravel_resources::config_key( + LaravelConfigResource::StorageDisk, + key, + ), + *is_write, + *is_optional, + )), _ => None, }) .collect() @@ -4710,7 +4725,7 @@ use Illuminate\Support\Facades\Storage; Storage::disk('archive'); "#; assert_eq!( - storage_disk_keys(&parse_and_extract(imported)), + storage_disk_keys(&parse_and_extract_semantic(imported)), vec![("filesystems.disks.archive".to_string(), false, false,)] ); @@ -4720,7 +4735,7 @@ use Illuminate\Support\Facades\Storage as LaravelStorage; LaravelStorage::fake('testing'); "#; assert_eq!( - storage_disk_keys(&parse_and_extract(aliased)), + storage_disk_keys(&parse_and_extract_semantic(aliased)), vec![("filesystems.disks.testing".to_string(), true, false,)] ); @@ -4729,14 +4744,14 @@ namespace App; class Storage {} Storage::disk('local'); "#; - assert!(storage_disk_keys(&parse_and_extract(homonym)).is_empty()); + assert!(storage_disk_keys(&parse_and_extract_semantic(homonym)).is_empty()); let root_qualified = r#" Vec<(String, bool, bool)> { + map.spans + .iter() + .filter_map(|span| match &span.kind { + SymbolKind::LaravelStringKey { + kind: LaravelStringKind::ConfigResource(span_resource), + key, + is_write, + is_optional, + } if *span_resource == resource => Some((key.clone(), *is_write, *is_optional)), + _ => None, + }) + .collect() +} + +#[test] +fn every_direct_resource_trigger_emits_its_short_name() { + for (php, resource, expected) in [ + ( + " 'stderr', $dynamic, ...$more]);", + " 'stderr', $dynamic));", + ] { + assert_eq!( + resource_keys(&parse_and_extract(php), LaravelConfigResource::LogChannel), + vec![ + ("daily".to_string(), false, false), + ("stderr".to_string(), false, false), + ] + ); + } + assert!( + resource_keys( + &parse_and_extract(" 'auth:api', + 'can:update,post', + 'AUTH:invalid', + 'CAN:invalid', + ], +); +"#; + let map = parse_and_extract(php); + assert_eq!( + resource_keys(&map, LaravelConfigResource::AuthGuard), + vec![ + ("web".to_string(), false, false), + ("admin".to_string(), false, false), + ("api".to_string(), false, false), + ] + ); + assert!(map.spans.iter().any(|span| matches!( + &span.kind, + SymbolKind::LaravelStringKey { + kind: LaravelStringKind::GateAbility, + key, + .. + } if key == "update" + ))); + for guard in ["web", "admin", "api"] { + let offset = php.find(guard).unwrap() as u32; + let span = map.lookup(offset).expect("guard should have a span"); + assert_eq!(&php[span.start as usize..span.end as usize], guard); + } +} + +#[test] +fn auth_middleware_skips_variadic_values_in_both_array_spellings() { + let php = r#" 'auth:api', ...$legacy)); +"#; + assert_eq!( + resource_keys(&parse_and_extract(php), LaravelConfigResource::AuthGuard,), + vec![ + ("web".to_string(), false, false), + ("api".to_string(), false, false), + ] + ); +} + +#[test] +fn a_route_middleware_chain_is_followed_only_within_the_depth_bound() { + for (chain, expected) in [ + ("Route::get('/')", true), + ("Route::get('/')->a()", true), + ("Route::get('/')->a()->b()->c()", true), + ("Route::get('/')->a()->b()->c()->d()", false), + ("$router->get('/')", false), + ] { + let php = format!("middleware('auth:web');\n"); + assert_eq!( + !resource_keys(&parse_and_extract(&php), LaravelConfigResource::AuthGuard,).is_empty(), + expected, + "{chain}" + ); + } +} + +#[test] +fn semantic_facade_aliases_cover_every_resource_and_reject_local_homonyms() { + let php = r#">(), + expected, + "{resource:?}" + ); + } +} + +#[test] +fn semantic_attribute_aliases_select_named_arguments_and_reject_homonyms() { + let php = r#"middleware(['auth:api']); +Route::middleware('auth:bad-static'); +Route::get('/local')->middleware('auth:bad-chain'); +"#; + assert_eq!( + resource_keys( + &parse_and_extract_semantic(php), + LaravelConfigResource::AuthGuard, + ), + vec![ + ("web".to_string(), false, false), + ("admin".to_string(), false, false), + ("api".to_string(), false, false), + ] + ); +} + +#[test] +fn semantic_auth_helper_preserves_global_fallback_and_rejects_shadows() { + let global_fallback = r#" $settings])", + ] { + assert_eq!( + config_keys_written(&parse_and_extract(&format!(" Option<&str> { None } -// ─── `use` statement scanning ─────────────────────────────────────────────── - -/// Call `visit` with the fully-qualified name and local name of every class -/// `use` item in the file, including the members of a group import. -pub(crate) fn for_each_class_import(content: &str, visit: &mut dyn FnMut(&str, &str)) { - for statement in content.split(';') { - let Some(clause) = use_clause(statement) else { - continue; - }; - // `use function …` / `use const …` import other symbol tables. - let mut words = clause.split_ascii_whitespace(); - if words.next().is_some_and(|word| { - word.eq_ignore_ascii_case("function") || word.eq_ignore_ascii_case("const") - }) { - continue; - } - - match clause.split_once('{') { - Some((prefix, items)) => { - let Some(items) = items.rsplit_once('}').map(|(items, _)| items) else { - continue; - }; - let prefix = prefix - .trim() - .trim_start_matches('\\') - .trim_end_matches('\\'); - for item in items.split(',') { - if let Some((name, local)) = use_item(item) { - visit(&format!("{prefix}\\{name}"), local); - } - } - } - None => { - for item in clause.split(',') { - if let Some((name, local)) = use_item(item) { - visit(name, local); - } - } - } - } - } -} - -/// Whether the file imports `fqn` under the local name `local`. -pub(crate) fn imports_class_as(content: &str, fqn: &str, local: &str) -> bool { - let mut imported = false; - for_each_class_import(content, &mut |imported_fqn, imported_local| { - imported |= - imported_local.eq_ignore_ascii_case(local) && imported_fqn.eq_ignore_ascii_case(fqn); - }); - imported -} - -/// The text after the `use` keyword of a statement that opens with one. -/// -/// Only the line the keyword sits on is examined, so an expression that -/// happens to precede the statement does not turn into an import. -fn use_clause(statement: &str) -> Option<&str> { - let mut offset = 0usize; - for line in statement.split_inclusive('\n') { - let trimmed = line.trim_start(); - if trimmed - .get(..4) - .is_some_and(|prefix| prefix.eq_ignore_ascii_case("use ")) - { - let leading = line.len() - trimmed.len(); - return Some(statement[offset + leading + 4..].trim()); - } - offset += line.len(); - } - None -} - -/// Split one `use` item into its imported name and the local name it binds. -fn use_item(item: &str) -> Option<(&str, &str)> { - let mut words = item.split_whitespace(); - let name = words.next()?.trim_start_matches('\\'); - let local = match words.next() { - Some(keyword) if keyword.eq_ignore_ascii_case("as") => words.next()?, - // Anything else is not an import PHP would accept. - Some(_) => return None, - None => name.rsplit('\\').next().unwrap_or(name), - }; - if words.next().is_some() || name.is_empty() || local.is_empty() { - return None; - } - Some((name, local)) -} - /// Whether the file declares a namespace. An unqualified name in a file /// without one resolves in the global namespace, where Laravel's class /// aliases live. diff --git a/src/virtual_members/laravel/config_keys.rs b/src/virtual_members/laravel/config_keys.rs index 87e6e1360..a061afad3 100644 --- a/src/virtual_members/laravel/config_keys.rs +++ b/src/virtual_members/laravel/config_keys.rs @@ -1,14 +1,15 @@ +use std::borrow::Cow; use std::sync::Arc; use mago_allocator::LocalArena; use mago_database::file::FileId; use mago_syntax::cst::*; -use tower_lsp::lsp_types::{Location, Position, Url}; +use tower_lsp::lsp_types::{Location, Position, Range, Url}; use crate::Backend; use crate::atom::bytes_to_str; use crate::references::push_unique_location; -use crate::symbol_map::{SymbolKind, SymbolMap}; +use crate::symbol_map::{LaravelStringKind, SymbolKind, SymbolMap, SymbolSpan}; use crate::text_position::offset_to_position; #[derive(Debug)] @@ -24,30 +25,14 @@ pub(crate) struct ConfigKeyMatch { /// Supports nested directories: `config/api/keys.php` returns `Some("api.keys")`. pub(crate) fn laravel_config_prefix_from_uri(uri: &str) -> Option { let parsed = Url::parse(uri).ok()?; - let path = parsed.path(); - let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); // Match the nearest `config` directory to the file path. This avoids // false negatives when an ancestor directory is also named `config`. - let config_idx = segments.iter().rposition(|seg| *seg == "config")?; - let file = segments.last()?; - if !file.ends_with(".php") { + let relative = parsed.path().rsplit_once("/config/")?.1; + let stem = relative.strip_suffix(".php")?; + if stem.is_empty() { return None; } - - let prefix_segments = &segments[config_idx + 1..]; - if prefix_segments.is_empty() { - return None; - } - - let mut stem_segments: Vec = prefix_segments.iter().map(|s| s.to_string()).collect(); - let last = stem_segments.last_mut()?; - *last = last.strip_suffix(".php")?.to_string(); - - if last.is_empty() { - return None; - } - - Some(stem_segments.join(".")) + Some(stem.replace('/', ".")) } /// Collect Laravel config declaration keys from a `config/*.php` file. @@ -63,7 +48,7 @@ pub(crate) fn collect_laravel_config_declarations( let program = mago_syntax::parser::parse_file_content(&arena, file_id, content.as_bytes()); let mut out = Vec::new(); - let mut returned_var_name: Option = None; + let mut returned_var_name: Option<&str> = None; let mut return_expr: Option<&Expression<'_>> = None; for stmt in program.statements.iter() { @@ -71,7 +56,7 @@ pub(crate) fn collect_laravel_config_declarations( if let Some(val) = ret.value { match val { Expression::Variable(Variable::Direct(dv)) => { - returned_var_name = Some(bytes_to_str(dv.name).to_string()); + returned_var_name = Some(bytes_to_str(dv.name)); } _ => { return_expr = Some(val); @@ -82,16 +67,17 @@ pub(crate) fn collect_laravel_config_declarations( } } + let mut path = Vec::new(); if let Some(expr) = return_expr { - collect_expr_declarations(expr, content, prefix, &[], &mut out); + collect_expr_declarations(expr, content, prefix, &mut path, &mut out); } else if let Some(var_name) = returned_var_name { for stmt in program.statements.iter() { if let Statement::Expression(expr_stmt) = stmt && let Expression::Assignment(assign) = expr_stmt.expression && let Expression::Variable(Variable::Direct(dv)) = assign.lhs - && dv.name == var_name.as_bytes() + && bytes_to_str(dv.name) == var_name { - collect_expr_declarations(assign.rhs, content, prefix, &[], &mut out); + collect_expr_declarations(assign.rhs, content, prefix, &mut path, &mut out); } } } @@ -101,11 +87,11 @@ pub(crate) fn collect_laravel_config_declarations( // ─── Declaration walker ─────────────────────────────────────────────────────── -fn collect_expr_declarations( +fn collect_expr_declarations<'content>( expr: &Expression<'_>, - content: &str, + content: &'content str, prefix: &str, - path: &[String], + path: &mut Vec<&'content str>, out: &mut Vec, ) { match expr { @@ -135,11 +121,11 @@ fn collect_expr_declarations( } } -fn collect_array_declarations<'a>( +fn collect_array_declarations<'a, 'content>( elements: impl Iterator>, - content: &str, + content: &'content str, prefix: &str, - path: &[String], + path: &mut Vec<&'content str>, out: &mut Vec, ) { for element in elements { @@ -152,16 +138,25 @@ fn collect_array_declarations<'a>( None => continue, }; - let mut full_path = path.to_vec(); - full_path.push(key_text.to_string()); - let dot_key = format!("{prefix}.{}", full_path.join(".")); + let capacity = prefix.len() + + path.iter().map(|segment| segment.len() + 1).sum::() + + key_text.len() + + 1; + let mut dot_key = String::with_capacity(capacity); + dot_key.push_str(prefix); + for segment in path.iter().copied().chain(std::iter::once(key_text)) { + dot_key.push('.'); + dot_key.push_str(segment); + } out.push(ConfigKeyMatch { key: dot_key, start: key_start, end: key_end, }); - collect_expr_declarations(kv.value, content, prefix, &full_path, out); + path.push(key_text); + collect_expr_declarations(kv.value, content, prefix, path, out); + path.pop(); } } @@ -176,11 +171,11 @@ fn config_write_keys(symbol_map: &SymbolMap) -> Vec { .iter() .filter_map(|span| match &span.kind { SymbolKind::LaravelStringKey { - kind: crate::symbol_map::LaravelStringKind::Config, + kind, key, is_write: true, .. - } => Some(key.clone()), + } if kind.is_config_backed() => Some(canonical_config_key(kind, key).into_owned()), _ => None, }) .collect(); @@ -190,45 +185,43 @@ fn config_write_keys(symbol_map: &SymbolMap) -> Vec { } impl Backend { - /// Record which config keys `uri` declares at runtime, so a later read of - /// one is judged against it. + /// Record the runtime config writes from each map being published, so a + /// later read is judged against the same active spans as navigation. /// - /// Called after every re-parse: a write the edit removed has to take its - /// key with it, which is why the file's whole set is replaced rather than - /// merged. Vendor files are left out for the same reason the enumeration - /// of `config/` files leaves them out: they are parsed on demand, so - /// including them would make what the diagnostic knows depend on which - /// classes happened to be loaded. - pub(crate) fn refresh_laravel_config_writes(&self, uri: &str) { + /// This includes batch indexing and maps whose facade alias was shadowed + /// or restored by an edit elsewhere. Each file's whole set is replaced: + /// removing a write must also withdraw the key it declared. Vendor maps + /// are excluded because their on-demand loading would otherwise make + /// diagnostics depend on which classes happened to be loaded. + pub(crate) fn refresh_laravel_config_writes<'a>( + &self, + maps: impl IntoIterator, + ) { if !self.resolved_class_cache.read().is_laravel() { return; } - let keys = self - .symbol_maps - .read() - .get(uri) - .map(|map| config_write_keys(map)) - .unwrap_or_default(); - - if keys.is_empty() { - // Only take the write lock when there is something to forget. - if self.laravel_runtime_config_keys.read().contains_key(uri) { - self.laravel_runtime_config_keys.write().remove(uri); + for (uri, map) in maps { + let keys = config_write_keys(map); + if keys.is_empty() { + // Only take the write lock when there is something to forget. + if self.laravel_runtime_config_keys.read().contains_key(uri) { + self.laravel_runtime_config_keys.write().remove(uri); + } + continue; + } + if self + .workspace + .vendor_uri_prefixes + .lock() + .iter() + .any(|prefix| uri.starts_with(prefix.as_str())) + { + continue; + } + let mut index = self.laravel_runtime_config_keys.write(); + if index.get(uri) != Some(&keys) { + index.insert(uri.to_string(), keys); } - return; - } - if self - .workspace - .vendor_uri_prefixes - .lock() - .iter() - .any(|prefix| uri.starts_with(prefix.as_str())) - { - return; - } - let mut index = self.laravel_runtime_config_keys.write(); - if index.get(uri) != Some(&keys) { - index.insert(uri.to_string(), keys); } } @@ -275,9 +268,13 @@ pub(crate) fn find_config_references( include_declaration: bool, ) -> Option> { // Fast path: cursor is on a usage site — symbol map already has the key. - let target_key = if let Some(sym) = backend.lookup_symbol_at_position(uri, content, position) { + let (target_kind, target_key) = if let Some(sym) = + backend.lookup_symbol_at_position(uri, content, position) + { match sym.kind { - SymbolKind::LaravelStringKey { key, .. } => key, + SymbolKind::LaravelStringKey { kind, key, .. } if kind.is_config_backed() => { + (kind, key) + } _ => return None, } } else { @@ -285,15 +282,23 @@ pub(crate) fn find_config_references( // This re-parses the current (single) config file — acceptable. let prefix = laravel_config_prefix_from_uri(uri)?; let cursor_offset = crate::text_position::position_to_offset(content, position) as usize; - collect_laravel_config_declarations(content, &prefix) + let key = collect_laravel_config_declarations(content, &prefix) .into_iter() .find(|d| cursor_offset >= d.start && cursor_offset <= d.end) - .map(|d| d.key)? + .map(|d| d.key)?; + (LaravelStringKind::Config, key) }; - let snapshot = backend.user_file_symbol_maps(); - let locations = - find_all_config_references(backend, &target_key, &snapshot, include_declaration); + let reference_key = + crate::reference_index::laravel_string_reference_key(target_kind, &target_key); + let snapshot = backend.user_file_symbol_maps_for_reference_keys(&[reference_key]); + let locations = find_all_config_references( + backend, + &target_kind, + &target_key, + &snapshot, + include_declaration, + ); if locations.is_empty() { return None; @@ -306,6 +311,25 @@ pub(crate) fn find_config_references( /// [`SymbolKind::LaravelStringKey`] span with `kind == Config` at the cursor — /// no file re-parse is needed for the usage side. pub(crate) fn resolve_config_key_declaration(backend: &Backend, key: &str) -> Option { + resolve_config_key_declaration_inner(backend, key, true) +} + +/// Resolve an exact config entry without falling back to the owning file. +/// +/// Named resources use this path so a misspelled resource never jumps to +/// line zero of an otherwise-valid config file. +pub(crate) fn resolve_config_key_declaration_exact( + backend: &Backend, + key: &str, +) -> Option { + resolve_config_key_declaration_inner(backend, key, false) +} + +fn resolve_config_key_declaration_inner( + backend: &Backend, + key: &str, + allow_file_fallback: bool, +) -> Option { let parts: Vec<&str> = key.split('.').collect(); let root = backend.workspace.workspace_root.read().clone()?; let config_dir = root.join("config"); @@ -325,30 +349,67 @@ pub(crate) fn resolve_config_key_declaration(backend: &Backend, key: &str) -> Op let stem = file_parts.join("."); let declarations = collect_laravel_config_declarations(&target_content, &stem); if let Some(decl) = declarations.into_iter().find(|d| d.key == key) { - let pos = crate::text_position::offset_to_position(&target_content, decl.start); - return Some(crate::definition::point_location(target_uri, pos)); + return Some(config_declaration_location( + target_uri, + &target_content, + &decl, + )); } - return Some(crate::definition::point_location( - target_uri, - Position::new(0, 0), - )); + if allow_file_fallback { + return Some(crate::definition::point_location( + target_uri, + Position::new(0, 0), + )); + } } } let first_part = parts.first()?; - for res in &backend.laravel_provider_resources.read().config_files { + let provider_configs: Vec<_> = backend + .laravel_provider_resources + .read() + .config_files + .iter() + .filter(|resource| resource.namespace == *first_part) + .cloned() + .collect(); + for res in &provider_configs { if res.namespace == *first_part && res.path.is_file() { let target_uri = Url::from_file_path(&res.path).ok()?; let target_content = std::fs::read_to_string(&res.path).ok()?; let declarations = collect_laravel_config_declarations(&target_content, &res.namespace); if let Some(decl) = declarations.into_iter().find(|d| d.key == key) { - let pos = crate::text_position::offset_to_position(&target_content, decl.start); - return Some(crate::definition::point_location(target_uri, pos)); + return Some(config_declaration_location( + target_uri, + &target_content, + &decl, + )); + } + if allow_file_fallback { + return Some(crate::definition::point_location( + target_uri, + Position::new(0, 0), + )); } - return Some(crate::definition::point_location( + } + } + + // Laravel's unpublished defaults are completion candidates, so an exact + // configured resource must navigate to the same framework declaration + // when the application and its providers do not override it. + let framework_path = root + .join("vendor/laravel/framework/config") + .join(format!("{first_part}.php")); + if framework_path.is_file() { + let target_uri = Url::from_file_path(&framework_path).ok()?; + let target_content = std::fs::read_to_string(&framework_path).ok()?; + let declarations = collect_laravel_config_declarations(&target_content, first_part); + if let Some(decl) = declarations.into_iter().find(|d| d.key == key) { + return Some(config_declaration_location( target_uri, - Position::new(0, 0), + &target_content, + &decl, )); } } @@ -356,18 +417,32 @@ pub(crate) fn resolve_config_key_declaration(backend: &Backend, key: &str) -> Op None } +fn config_declaration_location(uri: Url, content: &str, declaration: &ConfigKeyMatch) -> Location { + Location { + uri, + range: Range::new( + offset_to_position(content, declaration.start), + offset_to_position(content, declaration.end), + ), + } +} + /// Find all references for a Laravel config key across the project. /// /// Iterates pre-built [`SymbolKind::LaravelStringKey`] spans for usages /// (zero re-parses per file, same pattern as `find_member_references`). -/// Declaration lookup in `config/*.php` still uses an AST walk, but that -/// set is small (typically < 20 files) and each parse is cheap. +/// Declaration lookup parses only the config file that can own the canonical +/// key, independently of the usage-candidate snapshot. pub(crate) fn find_all_config_references( backend: &Backend, + target_kind: &LaravelStringKind, target_key: &str, snapshot: &[(String, Arc)], include_declaration: bool, ) -> Vec { + if !target_kind.is_config_backed() { + return Vec::new(); + } let mut locations = Vec::new(); // Usages: walk pre-built symbol spans — no file re-parse needed. @@ -381,13 +456,7 @@ pub(crate) fn find_all_config_references( None => continue, }; for span in &symbol_map.spans { - if let SymbolKind::LaravelStringKey { - kind: crate::symbol_map::LaravelStringKind::Config, - key, - .. - } = &span.kind - && key == target_key - { + if config_span_matches(span, target_kind, target_key) { let start = offset_to_position(&file_content, span.start as usize); let end = offset_to_position(&file_content, span.end as usize); push_unique_location(&mut locations, &parsed_uri, start, end); @@ -397,33 +466,67 @@ pub(crate) fn find_all_config_references( // Declarations: keys in config/*.php (small set, AST walk acceptable). if include_declaration { - for (file_uri, _) in snapshot { - let prefix = match laravel_config_prefix_from_uri(file_uri) { - Some(p) => p, - None => continue, - }; - let parsed_uri = match Url::parse(file_uri) { - Ok(u) => u, - Err(_) => continue, - }; - let file_content = match backend.get_file_content_arc(file_uri) { - Some(c) => c, - None => continue, - }; - for decl in collect_laravel_config_declarations(&file_content, &prefix) { - if decl.key != target_key { - continue; - } - let start = offset_to_position(&file_content, decl.start); - let end = offset_to_position(&file_content, decl.end); - push_unique_location(&mut locations, &parsed_uri, start, end); - } + let canonical_key = canonical_config_key(target_kind, target_key); + if let Some(declaration) = + resolve_config_key_declaration_exact(backend, canonical_key.as_ref()) + { + push_unique_location( + &mut locations, + &declaration.uri, + declaration.range.start, + declaration.range.end, + ); } } locations } +fn config_span_matches( + span: &SymbolSpan, + target_kind: &LaravelStringKind, + target_key: &str, +) -> bool { + matches!( + &span.kind, + SymbolKind::LaravelStringKey { kind, key, .. } + if config_keys_match(target_kind, target_key, kind, key) + ) +} + +fn config_keys_match( + left_kind: &LaravelStringKind, + left_key: &str, + right_kind: &LaravelStringKind, + right_key: &str, +) -> bool { + match (left_kind, right_kind) { + (LaravelStringKind::Config, LaravelStringKind::Config) => left_key == right_key, + (LaravelStringKind::ConfigResource(left), LaravelStringKind::ConfigResource(right)) => { + left == right + && crate::symbol_map::laravel_resources::same_resource_name( + *left, left_key, right_key, + ) + } + (LaravelStringKind::Config, LaravelStringKind::ConfigResource(resource)) => { + crate::symbol_map::laravel_resources::matches_config_key(*resource, right_key, left_key) + } + (LaravelStringKind::ConfigResource(resource), LaravelStringKind::Config) => { + crate::symbol_map::laravel_resources::matches_config_key(*resource, left_key, right_key) + } + _ => false, + } +} + +fn canonical_config_key<'a>(kind: &LaravelStringKind, key: &'a str) -> Cow<'a, str> { + match kind { + LaravelStringKind::ConfigResource(resource) => Cow::Owned( + crate::symbol_map::laravel_resources::config_key(*resource, key), + ), + _ => Cow::Borrowed(key), + } +} + /// Fallback for "go to definition" on a key inside config/*.php. /// /// Since array keys are not indexed in the symbol map, the generic @@ -463,7 +566,10 @@ mod tests { backend.update_ast( uri, - &Arc::new(" ['tenant' => ['driver' => 'array']]];\n", + ) + .unwrap(); + std::fs::write( + &provider_config, + " ['package' => ['driver' => 'array']]];\n", + ) + .unwrap(); + std::fs::write( + &second_provider_config, + " ['second' => ['driver' => 'array']]];\n", + ) + .unwrap(); + std::fs::write( + &framework_config, + " ['redis' => ['driver' => 'redis']]];\n", + ) + .unwrap(); + + let backend = Backend::new_test(); + *backend.workspace.workspace_root.write() = Some(dir.path().to_path_buf()); + backend + .laravel_provider_resources + .write() + .config_files + .extend([ + crate::virtual_members::laravel::ProviderResource { + path: stale_provider_config, + namespace: "cache".to_string(), + }, + crate::virtual_members::laravel::ProviderResource { + path: provider_config.clone(), + namespace: "cache".to_string(), + }, + crate::virtual_members::laravel::ProviderResource { + path: second_provider_config.clone(), + namespace: "cache".to_string(), + }, + ]); + + let app = resolve_config_key_declaration_exact(&backend, "cache.stores.tenant").unwrap(); + assert_eq!(app.uri, Url::from_file_path(app_config).unwrap()); + assert_ne!(app.range.start, app.range.end); + + let provider = + resolve_config_key_declaration_exact(&backend, "cache.stores.package").unwrap(); + assert_eq!(provider.uri, Url::from_file_path(provider_config).unwrap()); + + let second_provider = + resolve_config_key_declaration_exact(&backend, "cache.stores.second").unwrap(); + assert_eq!( + second_provider.uri, + Url::from_file_path(second_provider_config).unwrap() + ); + + let framework = + resolve_config_key_declaration_exact(&backend, "cache.stores.redis").unwrap(); + assert_eq!( + framework.uri, + Url::from_file_path(framework_config).unwrap() + ); + + assert!(resolve_config_key_declaration_exact(&backend, "cache.stores.missing").is_none()); + } + + #[test] + fn generic_config_lookup_can_fall_back_to_a_provider_file() { + let dir = tempfile::tempdir().unwrap(); + let provider_config = dir.path().join("vendor/package/resources/cache.php"); + let framework_config = dir.path().join("vendor/laravel/framework/config/cache.php"); + std::fs::create_dir_all(provider_config.parent().unwrap()).unwrap(); + std::fs::create_dir_all(framework_config.parent().unwrap()).unwrap(); + std::fs::write( + &provider_config, + " ['shared' => ['driver' => 'array']]];\n", + ) + .unwrap(); + std::fs::write( + &framework_config, + " ['shared' => ['driver' => 'redis']]];\n", + ) + .unwrap(); + + let backend = Backend::new_test(); + *backend.workspace.workspace_root.write() = Some(dir.path().to_path_buf()); + backend + .laravel_provider_resources + .write() + .config_files + .push(crate::virtual_members::laravel::ProviderResource { + path: provider_config.clone(), + namespace: "cache".to_string(), + }); + + let exact = resolve_config_key_declaration_exact(&backend, "cache.stores.shared").unwrap(); + assert_eq!(exact.uri, Url::from_file_path(&provider_config).unwrap()); + assert!(resolve_config_key_declaration_exact(&backend, "cache.stores.missing").is_none()); + + let fallback = resolve_config_key_declaration(&backend, "cache.stores.missing").unwrap(); + assert_eq!(fallback.uri, Url::from_file_path(provider_config).unwrap()); + assert_eq!( + fallback.range, + Range::new(Position::new(0, 0), Position::new(0, 0)) + ); + } } diff --git a/src/virtual_members/laravel/mod.rs b/src/virtual_members/laravel/mod.rs index c0345f8c2..9bc1f6030 100644 --- a/src/virtual_members/laravel/mod.rs +++ b/src/virtual_members/laravel/mod.rs @@ -151,7 +151,7 @@ pub(crate) use config_keys::find_config_references; pub(crate) use config_keys::{ collect_laravel_config_declarations, find_all_config_references, laravel_config_prefix_from_uri, resolve_config_key_declaration, - resolve_config_key_definition_fallback, + resolve_config_key_declaration_exact, resolve_config_key_definition_fallback, }; pub(crate) use const_eval::ClassContext; pub(crate) use env_vars::{enumerate_env_keys, env_declaration, env_name_is_sensitive}; @@ -172,6 +172,8 @@ pub(crate) use patches::STORAGE_FACADE_FQN; pub(crate) use path_helpers::{ collect_path_helper_links, is_path_helper, path_helper_base, resolve_path_helper_definition, }; +#[cfg(test)] +pub(crate) use provider_resources::ProviderResource; pub(crate) use provider_resources::{ ProviderIdentity, ProviderOrigin, ProviderResources, ProviderScan, ProviderScans, extract_provider_resources, @@ -182,8 +184,7 @@ pub(crate) use route_names::{ }; pub(crate) use storage::{ FILESYSTEM_MANAGER_FQN, LaravelStorageDriverIndex, StorageDriverRegistration, - extract_storage_driver_registrations, is_storage_facade_name, patch_storage_disk_type, - storage_facade_local_names, + extract_storage_driver_registrations, patch_storage_disk_type, }; pub(crate) use trans_keys::{collect_trans_declarations, trans_line, unresolved_trans_type}; pub(crate) use validation_rules::{safe_call_receiver_variable, safe_source_variable}; diff --git a/src/virtual_members/laravel/storage.rs b/src/virtual_members/laravel/storage.rs index b6ac443df..84e0910eb 100644 --- a/src/virtual_members/laravel/storage.rs +++ b/src/virtual_members/laravel/storage.rs @@ -392,62 +392,6 @@ impl LaravelStorageDriverIndex { } } -// ─── Storage facade name resolution ───────────────────────────────────────── - -/// The local names Laravel's `Storage` facade answers to in a file. -/// -/// Resolution is textual because the same question has to be answered for the -/// mid-edit buffer completion runs on and for the parsed file the symbol map -/// walks; an AST is only available on one of those. The result is a short list -/// (usually one name), so callers hold on to it for the file rather than -/// re-scanning per call site. -/// -/// A file with no `namespace` declaration reaches the facade through Laravel's -/// global class alias, so the bare short name counts there unless another -/// import has taken it. -pub(crate) fn storage_facade_local_names(content: &str) -> Vec { - let mut names: Vec = Vec::new(); - let mut short_name_taken = false; - - crate::text_scan::for_each_class_import(content, &mut |imported, local| { - if imported.eq_ignore_ascii_case(STORAGE_FACADE_FQN) { - names.push(local.to_string()); - } else if local.eq_ignore_ascii_case("Storage") { - short_name_taken = true; - } - }); - - if !short_name_taken - && !names - .iter() - .any(|name| name.eq_ignore_ascii_case("Storage")) - && !crate::text_scan::source_declares_namespace(content) - { - names.push("Storage".to_string()); - } - names -} - -/// Whether a written class name is Laravel's `Storage` facade, given the -/// local names [`storage_facade_local_names`] found for the file. -pub(crate) fn is_storage_facade_name(class_name: &str, local_names: &[String]) -> bool { - let is_root_qualified = class_name.starts_with('\\'); - let class_name = class_name.trim_start_matches('\\'); - if class_name.eq_ignore_ascii_case(STORAGE_FACADE_FQN) { - return true; - } - if class_name.contains('\\') { - return false; - } - if is_root_qualified { - // `\Storage` names the global alias whatever the file imports. - return class_name.eq_ignore_ascii_case("Storage"); - } - local_names - .iter() - .any(|local| local.eq_ignore_ascii_case(class_name)) -} - #[cfg(test)] #[path = "storage_tests.rs"] mod tests; diff --git a/src/virtual_members/laravel/storage_tests.rs b/src/virtual_members/laravel/storage_tests.rs index bd4d76f62..78836b38a 100644 --- a/src/virtual_members/laravel/storage_tests.rs +++ b/src/virtual_members/laravel/storage_tests.rs @@ -320,73 +320,3 @@ fn non_contract_return_is_left_untouched() { original.to_string() ); } - -// ─── Storage facade name resolution ───────────────────────────────────────── - -fn resolves(content: &str, class_name: &str) -> bool { - is_storage_facade_name(class_name, &storage_facade_local_names(content)) -} - -#[test] -fn the_facade_answers_to_its_fqn_its_imports_and_the_global_alias() { - let namespaced = " { backend.blade_block_definitions(uri, crate::blade::blocks::BlockKind::Stack, key) } - LaravelStringKind::Config => resolve_config_key_declaration(backend, key) - .into_iter() - .collect(), + LaravelStringKind::Config => { + if crate::symbol_map::laravel_resources::resource_from_config_key(key).is_some() { + resolve_config_key_declaration_exact(backend, key) + .into_iter() + .collect() + } else { + resolve_config_key_declaration(backend, key) + .into_iter() + .collect() + } + } + LaravelStringKind::ConfigResource(resource) => { + let config_key = crate::symbol_map::laravel_resources::config_key(*resource, key); + resolve_config_key_declaration_exact(backend, &config_key) + .into_iter() + .collect() + } LaravelStringKind::View => view_names::resolve_view_definitions(backend, key), LaravelStringKind::Route => route_names::resolve_route_definitions(backend, key), LaravelStringKind::Trans => trans_keys::resolve_trans_definitions(backend, key), @@ -211,8 +228,8 @@ pub(crate) fn find_laravel_string_key_references( ) -> Vec { use crate::symbol_map::LaravelStringKind; let mut locations = match kind { - LaravelStringKind::Config => { - find_all_config_references(backend, key, snapshot, include_declaration) + LaravelStringKind::Config | LaravelStringKind::ConfigResource(_) => { + find_all_config_references(backend, kind, key, snapshot, include_declaration) } // Two unrelated pages that both fill `content` fill two different // sections, so the span index's project-wide answer is the wrong @@ -241,7 +258,7 @@ pub(crate) fn find_laravel_string_key_references( | LaravelStringKind::Env => find_string_key_usages(kind, key, backend, snapshot), }; - if include_declaration && kind != &LaravelStringKind::Config { + if include_declaration && !kind.is_config_backed() { for decl in resolve_laravel_string_key(backend, kind, key, uri) { crate::references::push_unique_location( &mut locations, @@ -326,3 +343,61 @@ fn find_string_key_usages( } locations } + +#[cfg(test)] +mod tests { + use super::*; + use crate::symbol_map::{LaravelConfigResource, LaravelStringKind}; + + #[test] + fn configured_resources_resolve_exact_entries_without_file_fallback() { + let dir = tempfile::tempdir().unwrap(); + let config = dir.path().join("config/cache.php"); + std::fs::create_dir_all(config.parent().unwrap()).unwrap(); + std::fs::write( + &config, + " ['redis' => ['driver' => 'redis']]];\n", + ) + .unwrap(); + + let backend = crate::Backend::new_test(); + *backend.workspace.workspace_root.write() = Some(dir.path().to_path_buf()); + let usage_uri = "file:///project/usage.php"; + let resource_kind = LaravelStringKind::ConfigResource(LaravelConfigResource::CacheStore); + + let short = resolve_laravel_string_key(&backend, &resource_kind, "redis", usage_uri); + let full = resolve_laravel_string_key( + &backend, + &LaravelStringKind::Config, + "cache.stores.redis", + usage_uri, + ); + assert_eq!(short, full); + assert_eq!(short.len(), 1); + + assert!( + resolve_laravel_string_key(&backend, &resource_kind, "missing", usage_uri).is_empty() + ); + assert!( + resolve_laravel_string_key( + &backend, + &LaravelStringKind::Config, + "cache.stores.missing", + usage_uri, + ) + .is_empty() + ); + + let generic = resolve_laravel_string_key( + &backend, + &LaravelStringKind::Config, + "cache.unlisted", + usage_uri, + ); + assert_eq!(generic.len(), 1); + assert_eq!( + generic[0].range.start, + tower_lsp::lsp_types::Position::new(0, 0) + ); + } +} diff --git a/tests/integration/laravel_config_keys.rs b/tests/integration/laravel_config_keys.rs index 66dac31af..e9d8abcb0 100644 --- a/tests/integration/laravel_config_keys.rs +++ b/tests/integration/laravel_config_keys.rs @@ -116,7 +116,7 @@ async fn config_diagnostics_for( diags .iter() .filter( - |d| matches!(&d.code, Some(NumberOrString::String(s)) if s == "invalid_laravel_config"), + |d| matches!(&d.code, Some(NumberOrString::String(s)) if s == "invalid_laravel_config" || s == "invalid_laravel_storage_disk"), ) .map(|d| d.message.clone()) .collect() @@ -148,7 +148,7 @@ async fn a_key_written_at_runtime_is_a_declaration() { "only the disk nothing configures is unknown, got: {messages:?}" ); assert!( - messages[0].contains("filesystems.disks.nowhere"), + messages[0].contains("storage disk: 'nowhere'"), "the flagged key should be the unconfigured disk, got: {}", messages[0] ); diff --git a/tests/integration/laravel_named_resources.rs b/tests/integration/laravel_named_resources.rs new file mode 100644 index 000000000..9435f2f24 --- /dev/null +++ b/tests/integration/laravel_named_resources.rs @@ -0,0 +1,1286 @@ +//! End-to-end coverage for direct Laravel config-backed resource names. + +use crate::common::create_psr4_workspace; +use phpantom_lsp::Backend; +use tower_lsp::LanguageServer; +use tower_lsp::lsp_types::*; + +const COMPOSER_JSON: &str = r#"{ + "require": { "laravel/framework": "^12.0" }, + "autoload": { "psr-4": { "App\\": "app/" } } +}"#; + +const PACKAGE_COMPOSER_JSON: &str = r#"{ + "name": "acme/widgets", + "require": { "illuminate/support": "^12.0" }, + "require-dev": { "laravel/framework": "^12.0" }, + "autoload": { "psr-4": { "App\\": "app/" } } +}"#; + +const AUTH_CONFIG: &str = " ['web' => [], 'admin' => []]];\n"; +const CACHE_CONFIG: &str = " ['array' => [], 'redis' => []]];\n"; +const LOGGING_CONFIG: &str = " ['daily' => [], 'slack' => []]];\n"; +const FILESYSTEMS_CONFIG: &str = " ['local' => [], 'archive' => []]];\n"; +const DATABASE_CONFIG: &str = " ['mysql' => [], 'sqlite' => []]];\n"; +const QUEUE_CONFIG: &str = " ['sync' => [], 'redis' => []]];\n"; +const MAIL_CONFIG: &str = " ['smtp' => [], 'log' => []]];\n"; +const BROADCASTING_CONFIG: &str = + " ['reverb' => [], 'log' => []]];\n"; + +fn workspace_files(source: &str) -> Vec<(&str, &str)> { + vec![ + ("config/auth.php", AUTH_CONFIG), + ("config/cache.php", CACHE_CONFIG), + ("config/logging.php", LOGGING_CONFIG), + ("config/filesystems.php", FILESYSTEMS_CONFIG), + ("config/database.php", DATABASE_CONFIG), + ("config/queue.php", QUEUE_CONFIG), + ("config/mail.php", MAIL_CONFIG), + ("config/broadcasting.php", BROADCASTING_CONFIG), + ("app/NamedResourceConsumer.php", source), + ] +} + +fn position_at_offset(content: &str, offset: usize) -> Position { + let before = &content[..offset]; + Position::new( + before.bytes().filter(|byte| *byte == b'\n').count() as u32, + before + .rsplit_once('\n') + .map_or(before.len(), |(_, tail)| tail.len()) as u32, + ) +} + +fn position_after(content: &str, unique_prefix: &str) -> Position { + let offset = content + .find(unique_prefix) + .unwrap_or_else(|| panic!("missing `{unique_prefix}`")) + + unique_prefix.len(); + position_at_offset(content, offset) +} + +fn position_of_config_key(content: &str, key: &str) -> Position { + let marker = format!("'{key}' =>"); + let offset = content + .find(&marker) + .unwrap_or_else(|| panic!("missing config declaration `{marker}`")) + + 1; + position_at_offset(content, offset) +} + +fn definition_location(response: GotoDefinitionResponse) -> Location { + match response { + GotoDefinitionResponse::Scalar(location) => location, + GotoDefinitionResponse::Array(mut locations) => locations.remove(0), + GotoDefinitionResponse::Link(mut links) => { + let link = links.remove(0); + Location::new(link.target_uri, link.target_selection_range) + } + } +} + +fn text_in_range(content: &str, range: Range) -> &str { + let start = content + .split_inclusive('\n') + .take(range.start.line as usize) + .map(str::len) + .sum::() + + range.start.character as usize; + let end = content + .split_inclusive('\n') + .take(range.end.line as usize) + .map(str::len) + .sum::() + + range.end.character as usize; + &content[start..end] +} + +async fn open_workspace(source: &str) -> (Backend, tempfile::TempDir, Url) { + let files = workspace_files(source); + let (backend, dir) = create_psr4_workspace(COMPOSER_JSON, &files); + backend.initialized(InitializedParams {}).await; + let uri = Url::from_file_path(dir.path().join("app/NamedResourceConsumer.php")).unwrap(); + backend + .did_open(DidOpenTextDocumentParams { + text_document: TextDocumentItem { + uri: uri.clone(), + language_id: "php".to_string(), + version: 1, + text: source.to_string(), + }, + }) + .await; + (backend, dir, uri) +} + +async fn completion_items(backend: &Backend, uri: &Url, position: Position) -> Vec { + let response = backend + .completion(CompletionParams { + text_document_position: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { uri: uri.clone() }, + position, + }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + context: None, + }) + .await + .expect("completion request should succeed"); + match response { + Some(CompletionResponse::Array(items)) => items, + Some(CompletionResponse::List(list)) => list.items, + None => Vec::new(), + } +} + +fn hover_text(hover: Hover) -> String { + match hover.contents { + HoverContents::Markup(markup) => markup.value, + HoverContents::Scalar(MarkedString::String(text)) => text, + HoverContents::Scalar(MarkedString::LanguageString(text)) => text.value, + HoverContents::Array(parts) => parts + .into_iter() + .map(|part| match part { + MarkedString::String(text) => text, + MarkedString::LanguageString(text) => text.value, + }) + .collect::>() + .join("\n"), + } +} + +#[tokio::test] +async fn every_direct_resource_context_completes_only_direct_config_children() { + let source = r#">(), + *expected, + "completion at `{prefix}`" + ); + assert!( + items + .iter() + .all(|item| item.kind == Some(CompletionItemKind::PROPERTY)) + ); + } + + let middleware = completion_items( + &backend, + &uri, + position_after(source, "middleware('auth:web, a"), + ) + .await; + let Some(CompletionTextEdit::Edit(edit)) = &middleware[0].text_edit else { + panic!("middleware completion should carry an exact text edit"); + }; + assert_eq!(text_in_range(source, edit.range), " a"); + assert_eq!(edit.new_text, "admin"); +} + +#[tokio::test] +async fn completion_uses_semantic_aliases_and_rejects_wrong_shapes_and_homonyms() { + let source = r#"middleware('auth:a'); } +} + +LaravelCache::store(name: 'r'); +LaravelLog::stack(channels: ['s']); +LaravelRoute::middleware(middleware: 'auth:a'); +LaravelRoute::get('/', fn () => null)->middleware('auth:w'); +LaravelRoute::get('/')->name('home')->middleware('auth:w'); +LaravelRoute::get('/') + ->name('multiline') + ->middleware('auth:a'); +LaravelRoute::get('/', function () { return 1; })->middleware('auth:w'); +LaravelRoute::get('/', /* ) ] } */ fn () => null) // keep chaining + ->middleware('auth:w'); +LaravelRoute::get('/')->a()->b()->c()->d()->middleware('auth:w'); +factory(LaravelRoute::class)->middleware('auth:w'); +\Illuminate\Support\Facades\Cache::store('a'); +\Cache::store('r'); +\Route::middleware('auth:a'); +class AttributeTarget { + public function __construct( + #[CacheAttribute(store: 'r')] mixed $named, + #[\Deprecated('#['), CacheAttribute('r')] mixed $grouped, + #[ CacheAttribute('a')] mixed $spaced, + ) {} +} + +Cache::store('r'); +Route::middleware('auth:a'); +LaravelLog::stack('s'); +LaravelLog::stack(['key' => 'daily']); +LaravelCache::store(store: 'r'); +LaravelCache::store(NAME: 'r'); +LaravelCache::store('array', 'r'); +class LocalTarget { + public function __construct(#[Cache('r')] mixed $cache) {} +} +"#; + let (backend, _dir, uri) = open_workspace(source).await; + + for (prefix, expected) in [ + ("LaravelCache::store(name: 'r", vec!["redis"]), + ("LaravelLog::stack(channels: ['s", vec!["slack"]), + ("middleware(middleware: 'auth:a", vec!["admin"]), + ("get('/', fn () => null)->middleware('auth:w", vec!["web"]), + ("name('home')->middleware('auth:w", vec!["web"]), + ("name('multiline')\n ->middleware('auth:a", vec!["admin"]), + ("return 1; })->middleware('auth:w", vec!["web"]), + ("keep chaining\n ->middleware('auth:w", vec!["web"]), + ("Facades\\Cache::store('a", vec!["array"]), + ("\\Cache::store('r", vec!["redis"]), + ("\\Route::middleware('auth:a", vec!["admin"]), + ("CacheAttribute(store: 'r", vec!["redis"]), + ("Deprecated('#['), CacheAttribute('r", vec!["redis"]), + ("#[ CacheAttribute('a", vec!["array"]), + ] { + let labels = completion_items(&backend, &uri, position_after(source, prefix)) + .await + .into_iter() + .map(|item| item.label) + .collect::>(); + assert_eq!(labels, expected, "completion at `{prefix}`"); + } + + for prefix in [ + "\nCache::store('r", + "\nRoute::middleware('auth:a", + "factory(LaravelRoute::class)->middleware('auth:w", + "a()->b()->c()->d()->middleware('auth:w", + "LaravelLog::stack('s", + "LaravelLog::stack(['key", + "LaravelCache::store(store: 'r", + "LaravelCache::store(NAME: 'r", + "LaravelCache::store('array', 'r", + "#[Cache('r", + "$this->middleware('auth:a", + ] { + let labels = completion_items(&backend, &uri, position_after(source, prefix)).await; + assert!( + labels + .iter() + .all(|item| !["admin", "array", "daily", "redis", "slack", "web"] + .contains(&item.label.as_str())), + "invalid context `{prefix}` offered resource names: {labels:?}" + ); + } +} + +#[tokio::test] +async fn auth_helper_completion_respects_php_namespace_fallback_and_function_homonyms() { + let global_fallback = r#">(); + assert_eq!(labels, ["admin", "web"]); + + let fully_qualified = r#">(); + assert_eq!(labels, ["admin", "web"]); + + let imported_global_alias = r#">(); + assert_eq!(labels, ["admin", "web"]); + + for source in [ + r#">(); + assert_eq!(labels, expected, "completion at `{prefix}`"); + } +} + +#[tokio::test] +async fn a_cross_file_namespaced_auth_helper_shadows_laravels_global_fallback() { + let source = r#">(); + let (backend, dir) = create_psr4_workspace(COMPOSER_JSON, &files); + backend.initialized(InitializedParams {}).await; + let uri = Url::from_file_path(dir.path().join("app/NamedResourceConsumer.php")).unwrap(); + backend + .did_open(DidOpenTextDocumentParams { + text_document: TextDocumentItem { + uri: uri.clone(), + language_id: "php".to_string(), + version: 1, + text: source.to_string(), + }, + }) + .await; + let position = position_after(source, "auth('we"); + + let labels = completion_items(&backend, &uri, position) + .await + .into_iter() + .map(|item| item.label) + .collect::>(); + assert!( + labels + .iter() + .all(|label| label != "web" && label != "admin"), + "project auth() helper offered Laravel guards: {labels:?}" + ); + assert!( + backend + .handle_hover(uri.as_str(), source, position) + .is_none() + ); + assert!( + backend + .goto_definition(GotoDefinitionParams { + text_document_position_params: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { uri: uri.clone() }, + position, + }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + }) + .await + .expect("definition request should succeed") + .is_none() + ); + assert!( + backend + .find_references(uri.as_str(), source, position, true) + .is_none() + ); +} + +#[tokio::test] +async fn real_global_classes_shadow_laravels_optional_facade_aliases() { + let source = r#">(); + let (backend, dir) = create_psr4_workspace(COMPOSER_JSON, &files); + backend.initialized(InitializedParams {}).await; + let uri = Url::from_file_path(dir.path().join("app/NamedResourceConsumer.php")).unwrap(); + backend + .did_open(DidOpenTextDocumentParams { + text_document: TextDocumentItem { + uri: uri.clone(), + language_id: "php".to_string(), + version: 1, + text: source.to_string(), + }, + }) + .await; + + for prefix in ["\\Cache::store('red", "\\Route::middleware('auth:we"] { + let position = position_after(source, prefix); + let labels = completion_items(&backend, &uri, position).await; + assert!( + labels + .iter() + .all(|item| !["admin", "redis", "web"].contains(&item.label.as_str())), + "global facade homonym offered Laravel resources: {labels:?}" + ); + assert!( + backend + .handle_hover(uri.as_str(), source, position) + .is_none() + ); + assert!( + backend + .find_references(uri.as_str(), source, position, true) + .is_none() + ); + } + + let mut diagnostics = Vec::new(); + backend.collect_slow_diagnostics(uri.as_str(), source, &mut diagnostics); + assert!(diagnostics.iter().all(|diagnostic| { + !matches!( + &diagnostic.code, + Some(NumberOrString::String(code)) if code.starts_with("invalid_laravel_") + ) + })); +} + +#[tokio::test] +async fn family_hovers_and_diagnostics_use_specific_labels_codes_and_ranges() { + let source = r#" { + Some((code.as_str(), text_in_range(source, diagnostic.range))) + } + _ => None, + }) + .collect::>(); + + assert_eq!(invalid.len(), 8, "diagnostics: {invalid:#?}"); + + for (code, payload) in [ + ("invalid_laravel_auth_guard", " missing-auth"), + ("invalid_laravel_cache_store", "missing-cache"), + ("invalid_laravel_log_channel", "missing-log"), + ("invalid_laravel_storage_disk", "missing-disk"), + ("invalid_laravel_database_connection", "missing-database"), + ("invalid_laravel_queue_connection", "missing-queue"), + ("invalid_laravel_mailer", "missing-mailer"), + ("invalid_laravel_broadcast_connection", "missing-broadcast"), + ] { + assert_eq!( + invalid.iter().find_map(|(actual_code, actual_payload)| { + (*actual_code == code).then_some(actual_payload) + }), + Some(&payload), + "diagnostics: {invalid:#?}" + ); + } +} + +#[tokio::test] +async fn parameter_attributes_share_completion_navigation_diagnostics_and_references() { + let source = r#">(); + assert_eq!(labels, ["redis"]); + + let hover = backend + .handle_hover(uri.as_str(), source, valid) + .expect("parameter attribute should hover as a cache store"); + assert!(hover_text(hover).contains("**Cache store** `redis`")); + + let definition = backend + .goto_definition(GotoDefinitionParams { + text_document_position_params: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { uri: uri.clone() }, + position: valid, + }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + }) + .await + .expect("definition request should succeed") + .expect("parameter attribute should navigate"); + let definition = definition_location(definition); + assert!(definition.uri.path().ends_with("/config/cache.php")); + assert_eq!( + definition.range.start, + position_of_config_key(CACHE_CONFIG, "redis") + ); + + let references = backend + .find_references(uri.as_str(), source, valid, true) + .expect("parameter attribute should share config references"); + assert_eq!(references.len(), 3, "references: {references:#?}"); + + let mut diagnostics = Vec::new(); + backend.collect_slow_diagnostics(uri.as_str(), source, &mut diagnostics); + let invalid = diagnostics + .iter() + .find(|diagnostic| { + matches!( + &diagnostic.code, + Some(NumberOrString::String(code)) if code == "invalid_laravel_cache_store" + ) + }) + .expect("invalid parameter attribute should be diagnosed"); + assert_eq!(text_in_range(source, invalid.range), "missing-store"); +} + +#[tokio::test] +async fn database_roles_and_runtime_null_drivers_follow_laravel_semantics() { + let source = r#">(); + assert_eq!(labels, ["mysql::read", "mysql::write", "mysql::direct"]); + + let read = position_after(source, "DB::connection('mysql::rea"); + let hover = backend + .handle_hover(uri.as_str(), source, read) + .expect("role-qualified database connection should hover"); + assert!(hover_text(hover).contains("**Database connection** `mysql::read`")); + let definition = backend + .goto_definition(GotoDefinitionParams { + text_document_position_params: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { uri: uri.clone() }, + position: read, + }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + }) + .await + .expect("definition request should succeed") + .expect("role-qualified database connection should navigate"); + assert_eq!( + definition_location(definition).range.start, + position_of_config_key(DATABASE_CONFIG, "mysql") + ); + + let references = backend + .find_references(uri.as_str(), source, read, true) + .expect("all database roles should share one config identity"); + assert_eq!(references.len(), 5, "references: {references:#?}"); + + let mut diagnostics = Vec::new(); + backend.collect_slow_diagnostics(uri.as_str(), source, &mut diagnostics); + let invalid = diagnostics + .iter() + .filter(|diagnostic| { + matches!( + &diagnostic.code, + Some(NumberOrString::String(code)) if code.starts_with("invalid_laravel_") + ) + }) + .collect::>(); + assert_eq!(invalid.len(), 1, "diagnostics: {invalid:#?}"); + assert_eq!(text_in_range(source, invalid[0].range), "mysql::replica"); + + for marker in ["Cache::store('nul", "Queue::connection('nul"] { + let position = position_after(source, marker); + let references = backend + .find_references(uri.as_str(), source, position, true) + .expect("implicit null driver should retain direct references"); + assert_eq!(references.len(), 1, "references at `{marker}`"); + } +} + +#[tokio::test] +async fn generic_config_diagnostics_accept_only_exact_segment_prefixes() { + let source = " ['from' => 'team@example.com']];\n", + ), + ("app/NamedResourceConsumer.php", source), + ], + ); + backend.initialized(InitializedParams {}).await; + let uri = Url::from_file_path(dir.path().join("app/NamedResourceConsumer.php")).unwrap(); + backend + .did_open(DidOpenTextDocumentParams { + text_document: TextDocumentItem { + uri: uri.clone(), + language_id: "php".to_string(), + version: 1, + text: source.to_string(), + }, + }) + .await; + + let mut diagnostics = Vec::new(); + backend.collect_slow_diagnostics(uri.as_str(), source, &mut diagnostics); + let invalid = diagnostics + .iter() + .filter(|diagnostic| { + matches!( + &diagnostic.code, + Some(NumberOrString::String(code)) if code == "invalid_laravel_config" + ) + }) + .collect::>(); + assert_eq!(invalid.len(), 1, "diagnostics: {invalid:#?}"); + assert_eq!(text_in_range(source, invalid[0].range), "app.ma"); +} + +#[tokio::test] +async fn library_resource_names_belong_to_the_host_application() { + let source = r#">(); + assert!(invalid.is_empty(), "diagnostics: {invalid:#?}"); + + let labels = completion_items(&backend, &uri, position_after(source, "Cache::store('r")) + .await + .into_iter() + .map(|item| item.label) + .collect::>(); + assert_eq!(labels, ["redis"]); +} + +#[tokio::test] +async fn runtime_config_writes_validate_resources_across_files_at_segment_boundaries() { + let writer = r#" 'session']); + config(['cache.stores.temp.driver' => 'array']); + Config::set('logging.channels', $channels); + Config::set('filesystems', $filesystems); + Config::set('database.connections.reporting', ['driver' => 'mysql']); + Config::set('queue.connections.custom', ['driver' => 'sync']); + Config::set('mail.mailers.outbound.transport', 'smtp'); + Config::set('broadcasting.connections.realtime', ['driver' => 'reverb']); + Config::set('external.runtime', []); + } +} +"#; + let source = r#">(); + assert_eq!( + invalid, + [ + "session-typo", + "tem", + "temporary", + "reporting::replica", + "custom-typo", + "out", + "realtime-typo", + "cache.stores.te", + "cache.stores.temporary.driver", + ] + ); +} + +#[tokio::test] +async fn an_undiscovered_resource_subtree_is_not_treated_as_a_closed_empty_set() { + let source = r#" ['driver' => 'array']]); +Cache::store('provided-at-runtime'); +"#; + let (backend, dir) = create_psr4_workspace( + COMPOSER_JSON, + &[ + ("config/cache.php", " 'array'];\n"), + ("app/NamedResourceConsumer.php", source), + ], + ); + backend.initialized(InitializedParams {}).await; + let uri = Url::from_file_path(dir.path().join("app/NamedResourceConsumer.php")).unwrap(); + backend + .did_open(DidOpenTextDocumentParams { + text_document: TextDocumentItem { + uri: uri.clone(), + language_id: "php".to_string(), + version: 1, + text: source.to_string(), + }, + }) + .await; + + let mut diagnostics = Vec::new(); + backend.collect_slow_diagnostics(uri.as_str(), source, &mut diagnostics); + assert!(diagnostics.iter().all(|diagnostic| { + !matches!( + &diagnostic.code, + Some(NumberOrString::String(code)) if code == "invalid_laravel_cache_store" + ) + })); +} + +#[tokio::test] +async fn direct_and_generic_config_spellings_share_one_reference_set() { + let source = r#">(); - assert_eq!(invalid_config.len(), 2, "got: {invalid_config:#?}"); + assert_eq!(invalid_disks.len(), 2, "got: {invalid_disks:#?}"); - let messages = invalid_config + let messages = invalid_disks .iter() .map(|diagnostic| diagnostic.message.as_str()) .collect::>(); assert!( messages .iter() - .any(|message| message.contains("filesystems.disks.missing-disk")) + .any(|message| message.contains("storage disk: 'missing-disk'")) ); assert!( messages .iter() - .any(|message| message.contains("filesystems.disks.missing-attribute")) + .any(|message| message.contains("storage disk: 'missing-attribute'")) ); for optional_or_written in [ "testing", diff --git a/tests/integration/laravel_string_key_non_laravel_gate.rs b/tests/integration/laravel_string_key_non_laravel_gate.rs index df63a7769..da22b353a 100644 --- a/tests/integration/laravel_string_key_non_laravel_gate.rs +++ b/tests/integration/laravel_string_key_non_laravel_gate.rs @@ -28,6 +28,15 @@ return [ ]; "; +const FILESYSTEMS_CONFIG: &str = "\ + [ + 'archive' => ['driver' => 'local'], + ], +]; +"; + const CONSUMER: &str = "\ (phpantom_lsp::Backend, tempfile::TempDir, Url) { let (backend, dir) = create_psr4_workspace( composer_json, @@ -65,6 +85,45 @@ async fn setup(composer_json: &str) -> (phpantom_lsp::Backend, tempfile::TempDir (backend, dir, uri) } +async fn setup_storage_homonym() -> (phpantom_lsp::Backend, tempfile::TempDir, Url) { + let (backend, dir) = create_psr4_workspace( + COMPOSER_JSON_NON_LARAVEL, + &[ + ("config/filesystems.php", FILESYSTEMS_CONFIG), + ("src/StorageConsumer.php", STORAGE_HOMONYM_CONSUMER), + ], + ); + backend.initialized(InitializedParams {}).await; + + let uri = Url::from_file_path(dir.path().join("src/StorageConsumer.php")).unwrap(); + backend + .did_open(DidOpenTextDocumentParams { + text_document: TextDocumentItem { + uri: uri.clone(), + language_id: "php".to_string(), + version: 1, + text: STORAGE_HOMONYM_CONSUMER.to_string(), + }, + }) + .await; + + (backend, dir, uri) +} + +fn position_after(content: &str, unique_prefix: &str) -> Position { + let offset = content + .find(unique_prefix) + .unwrap_or_else(|| panic!("missing `{unique_prefix}`")) + + unique_prefix.len(); + let before = &content[..offset]; + Position::new( + before.bytes().filter(|byte| *byte == b'\n').count() as u32, + before + .rsplit_once('\n') + .map_or(before.len(), |(_, tail)| tail.len()) as u32, + ) +} + /// Lands inside the `'app.name'` string literal on the first `config()` /// call (line 5, 0-based) in `CONSUMER`. const KEY_POSITION: Position = Position { @@ -72,6 +131,85 @@ const KEY_POSITION: Position = Position { character: 20, }; +#[tokio::test] +async fn non_laravel_direct_resource_homonym_has_no_laravel_editor_features() { + let (backend, _dir, uri) = setup_storage_homonym().await; + let position = position_after(STORAGE_HOMONYM_CONSUMER, "Storage::disk('arch"); + + let completion = backend + .completion(CompletionParams { + text_document_position: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { uri: uri.clone() }, + position, + }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + context: None, + }) + .await + .unwrap(); + let completion_items = match completion { + Some(CompletionResponse::Array(items)) => items, + Some(CompletionResponse::List(list)) => list.items, + None => Vec::new(), + }; + assert!( + completion_items.iter().all(|item| item.label != "archive"), + "a non-Laravel Storage homonym must not complete configured disks, got {completion_items:?}" + ); + + let hover = backend + .hover(HoverParams { + text_document_position_params: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { uri: uri.clone() }, + position, + }, + work_done_progress_params: WorkDoneProgressParams::default(), + }) + .await + .unwrap(); + assert!( + hover.is_none(), + "a non-Laravel Storage homonym must not hover as a storage disk, got {hover:?}" + ); + + let definition = backend + .goto_definition(GotoDefinitionParams { + text_document_position_params: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { uri: uri.clone() }, + position, + }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + }) + .await + .unwrap(); + assert!( + definition.is_none(), + "a non-Laravel Storage homonym must not jump to filesystems.php, got {definition:?}" + ); + + let references = backend + .references(ReferenceParams { + text_document_position: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { uri }, + position, + }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + context: ReferenceContext { + include_declaration: true, + }, + }) + .await + .unwrap() + .unwrap_or_default(); + assert!( + references.is_empty(), + "a non-Laravel Storage homonym must not fabricate disk references, got {references:?}" + ); +} + #[tokio::test] async fn non_laravel_project_hover_does_not_fabricate_config_key() { let (backend, _dir, uri) = setup(COMPOSER_JSON_NON_LARAVEL).await; diff --git a/tests/integration/main.rs b/tests/integration/main.rs index 886fedec6..5bb7fc60f 100644 --- a/tests/integration/main.rs +++ b/tests/integration/main.rs @@ -172,6 +172,7 @@ mod laravel_gates; mod laravel_macro_facade; mod laravel_macros; mod laravel_morph_map; +mod laravel_named_resources; mod laravel_path_helpers; mod laravel_provider_refresh; mod laravel_references;