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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 112 additions & 3 deletions benches/laravel_completion.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use criterion::{Criterion, black_box, criterion_group, criterion_main};
use criterion::{BatchSize, BenchmarkId, Criterion, black_box, criterion_group, criterion_main};
use phpantom_lsp::Backend;
use std::collections::HashMap;
use tower_lsp::LanguageServer;
Expand All @@ -13,8 +13,10 @@ fn rt() -> tokio::runtime::Runtime {

async fn setup_laravel_backend() -> Backend {
let mut stubs = HashMap::new();
stubs.insert("Illuminate\\Database\\Eloquent\\Model", "<?php namespace Illuminate\\Database\\Eloquent; class Model { public static function query(): Builder {} }");
stubs.insert("Illuminate\\Database\\Eloquent\\Model", "<?php namespace Illuminate\\Database\\Eloquent; class Model { public static function query(): Builder {} public function morphTo(): Relations\\MorphTo {} }");
stubs.insert("Illuminate\\Database\\Eloquent\\Builder", "<?php namespace Illuminate\\Database\\Eloquent; class Builder { public function where($column): self {} public function whereIn($column, $values): self {} public function orWhere($column): self {} }");
stubs.insert("Illuminate\\Database\\Eloquent\\Relations\\Relation", "<?php namespace Illuminate\\Database\\Eloquent\\Relations; class Relation { public static function morphMap(array $map): void {} }");
stubs.insert("Illuminate\\Database\\Eloquent\\Relations\\MorphTo", "<?php namespace Illuminate\\Database\\Eloquent\\Relations; class MorphTo extends Relation {}");

Backend::new_test_with_stubs(stubs)
}
Expand Down Expand Up @@ -115,5 +117,112 @@ fn bench_laravel_model_completion(c: &mut Criterion) {
group.finish();
}

criterion_group!(benches, bench_laravel_model_completion);
fn generate_morph_column_source(literal_count: usize) -> (String, Position, Range) {
let mut source = String::from(
r#"<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Database\Eloquent\Relations\Relation;

class Post extends Model {}
class Comment extends Model {
protected $table = 'comments';
public function subject(): MorphTo { return $this->morphTo(); }
}

Relation::morphMap(['post' => Post::class]);
Comment::whereIn('comments.subject_type', ["#,
);
for _ in 1..literal_count {
source.push_str("'post', ");
}
let alias_start = source.len() + 1;
source.push_str("'po']);\n");

let before_alias = &source[..alias_start];
let line = before_alias.bytes().filter(|byte| *byte == b'\n').count() as u32;
let character = before_alias.rsplit('\n').next().unwrap().len() as u32;
let start = Position::new(line, character);
let end = Position::new(line, character + 2);
(source, end, Range::new(start, end))
}

fn assert_morph_alias_completion(response: Option<CompletionResponse>, expected_range: Range) {
let items = match response.expect("morph column completion must return a response") {
CompletionResponse::Array(items) => items,
CompletionResponse::List(list) => list.items,
};
assert_eq!(items.len(), 1, "expected only the registered morph alias");
assert_eq!(items[0].label, "post");
assert_eq!(items[0].kind, Some(CompletionItemKind::ENUM_MEMBER));
let Some(CompletionTextEdit::Edit(edit)) = &items[0].text_edit else {
panic!("morph alias completion must replace the literal contents");
};
assert_eq!(edit.range, expected_range);
assert_eq!(edit.new_text, "post");
}

fn bench_morph_column_completion(c: &mut Criterion) {
let runtime = rt();
let mut group = c.benchmark_group("laravel_morph_column_completion");

for literal_count in [1, 128] {
let backend = runtime.block_on(setup_laravel_backend());
let (source, position, range) = generate_morph_column_source(literal_count);
let uri = runtime.block_on(open_file(
&backend,
&format!("file:///bench/morph_columns_{literal_count}.php"),
&source,
));
let params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri: uri.clone() },
position,
},
context: Some(CompletionContext {
trigger_kind: CompletionTriggerKind::INVOKED,
trigger_character: None,
}),
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
};
let request = || {
runtime
.block_on(backend.completion(params.clone()))
.expect("morph column completion request failed")
};

backend.update_ast(uri.as_str(), &source);
backend.clear_completion_cache();
assert_morph_alias_completion(request(), range);
assert_morph_alias_completion(request(), range);

group.bench_function(BenchmarkId::new("cold_confirmation", literal_count), |b| {
// Setups mutate shared cache state, so each must precede exactly
// one timed request instead of being grouped into larger batches.
b.iter_batched(
|| {
backend.update_ast(uri.as_str(), &source);
backend.clear_completion_cache();
},
|()| black_box(request()),
BatchSize::PerIteration,
);
});

group.bench_function(BenchmarkId::new("cached_request", literal_count), |b| {
assert_morph_alias_completion(request(), range);
b.iter(|| black_box(request()));
});
}

group.finish();
}

criterion_group!(
benches,
bench_laravel_model_completion,
bench_morph_column_completion
);
criterion_main!(benches);
1 change: 1 addition & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,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.
- **Candidate morph-column aliases** (`morph_column_sites`): literals compared with properties or query columns are confirmed lazily by `virtual_members/laravel/typed_morph_columns.rs`. The shared type engine identifies the receiver model, and its parsed `morphTo()` metadata identifies the column. Array values in one query share a single model and column check. Confirmed literals become ordinary `MorphAlias` spans for completion, navigation, references, and diagnostics. Model metadata changes invalidate dependent confirmations, including when a relation's column changes without changing its PHP signature.

### Tier 2: Stored Byte Offsets (cross-file jumps)

Expand Down
1 change: 1 addition & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **Morph aliases resolve in column comparisons.** Comparing a model's polymorphic type column with a registered alias now offers completion, hover, go-to-definition, and find-references, and reports unknown aliases when the morph map is enforced. Query filters and property comparisons recognize the columns declared by the model's relationships, including custom names and inherited relations. Contributed by @shuvroroy.
- **Formatting from the command line.** `phpantom_lsp format` formats every PHP file and Blade template in a project with the same formatter the editor runs on save, and `phpantom_lsp format --check` reports the files that are not formatted and exits non-zero without writing anything, so a CI job can require that a pull request ran the formatter. A run honours whatever the project already formats with, a Laravel Pint, php-cs-fixer, or PHP_CodeSniffer it depends on, and the built-in formatter otherwise, exactly as the editor resolves it, and opens with a line naming what it resolved so a CI log records which formatter enforced the result. Templates whose indentation is output rather than layout are left alone and never fail a check, and formatting turned off in `.phpantom.toml` is reported as such rather than passing as a project where every file happens to be formatted. Paths can be named to restrict the run, `--format github` annotates the pull request diff, and `--format json` is shaped like the object `analyze` and `fix` emit.
- **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.
Expand Down
1 change: 0 additions & 1 deletion docs/todo.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,6 @@ unlikely to move the needle for most users.
| L12 | [`HasUuids` / `HasUlids` trait — `$id` typed as `string`](todo/laravel.md#l12-hasuuids-hasulids-trait-id-typed-as-string) | Low-Medium | Medium |
| L44 | [Sibling resource registrations and degenerate resource names](todo/laravel.md#l44-sibling-resource-registrations-and-degenerate-resource-names) | Low-Medium | Medium |
| L50 | ["Create route" quick-fix for an unresolved route name](todo/laravel.md#l50-create-route-quick-fix-for-an-unresolved-route-name) | Low-Medium | Medium |
| L47 | [Morph aliases in `*_type` column comparisons](todo/laravel.md#l47-morph-aliases-in-_type-column-comparisons) | Low-Medium | Medium-High |
| L8 | `withSum`/`withAvg`/`withMin`/`withMax` aggregate properties | Low-Medium | High |
| L45 | [`*_count` properties are offered on every relationship](todo/laravel.md#l45-_count-properties-are-offered-on-every-relationship) | Low-Medium | High |
| L29 | [Livewire and Volt component names](todo/laravel.md#l29-livewire-and-volt-component-names) (Livewire projects only) | Low | Low |
Expand Down
30 changes: 1 addition & 29 deletions docs/todo/laravel.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ within the same impact tier.
| Facade → concrete resolution via booting | Requires booting (`getFacadeRoot()`). When `getFacadeAccessor()` returns a `::class` reference, static resolution is possible without booting. See "Facade completion" section below. |
| Contract → concrete resolution | Fully out of scope, including core framework contracts. Calling a concrete-only method on a contract-typed value is unsound per the declared types — the diagnostic is intended, exactly as `fn (A $a) => $a->bMethod()` is not a false positive just because `B extends A` at every call site. Where the *framework's own* docblock is needlessly wide, fix the docblock upstream or via stub patches. |
| Manager → driver resolution | Requires instantiating the manager at runtime. |
| Narrowing a `MorphTo` relation to concrete models | `$comment->commentable` resolves to the generic `Illuminate\Database\Eloquent\Model`, which is what the relation declares. The morph map (now indexed, see L47/L42) is global rather than per-relation, so the only type it could supply is a union of *every* mapped model — a sound upper bound that is far wider than the truth and would report a concrete method as "not found on any of the N possible types". Annotate the relation with `@return MorphTo<Post\|Video, $this>` where the target set is actually known. |
| Narrowing a `MorphTo` relation to concrete models | `$comment->commentable` resolves to the generic `Illuminate\Database\Eloquent\Model`, which is what the relation declares. The morph map is global rather than per-relation, so the only type it could supply is a union of *every* mapped model — a sound upper bound that is far wider than the truth and would report a concrete method as "not found on any of the N possible types". Annotate the relation with `@return MorphTo<Post\|Video, $this>` where the target set is actually known. |

---

Expand Down Expand Up @@ -379,34 +379,6 @@ Alternatively, if the stubs for these traits include `@property`
tags or a typed `$id` override, the PHPDoc provider may handle it
automatically once the traits are loaded.

#### L47. Morph aliases in `*_type` column comparisons

**Impact: Low-Medium · Complexity: Medium-High**

The morph-map index (`virtual_members/laravel/morph_map.rs`) recognizes
alias strings in the positions Eloquent resolves through the map by
name: the `morphMap()` keys themselves, `Relation::getMorphedModel()`,
`Model::getActualClassNameForMorph()`, and the `$types` argument of the
`whereHasMorph()` family. It does **not** recognize an alias compared
against a morph type *column*, which is how much real code reads it:

```php
$query->where('commentable_type', 'post');
if ($comment->commentable_type === 'post') { … }
```

Both are alias literals, but recognizing them means knowing that the
column named on the other side is a polymorphic type column. The
information is available: a `morphTo()` relation declares its type
column (defaulting to `<relation>_type`), and the relation methods of
the model being queried are already parsed. The work is to collect the
morph type columns of a model, then match a string literal that appears
opposite one in a `where()` / comparison against the alias index.

Once a literal is recognized it inherits hover, go-to-definition,
find-references, and the enforced-map diagnostic for free, since those
dispatch on the `LaravelStringKind::MorphAlias` span kind.

#### L42. Morph alias completion in array positions

**Impact: Low-Medium · Complexity: Medium**
Expand Down
11 changes: 11 additions & 0 deletions examples/laravel/app/Demo.php
Original file line number Diff line number Diff line change
Expand Up @@ -884,6 +884,17 @@ public function morphAliases(): void
// The same alias resolves in Relation::getMorphedModel(), where
// completion also offers the registered aliases.
Relation::getMorphedModel('blog_post'); // → App\Models\BlogPost

// The relation declares reviewable_type as its morph type column.
// Aliases in query values and property comparisons also complete,
// hover as BlogPost, and navigate to the model and registration.
Review::where('reviewable_type', 'blog_post')->get();
Review::query()->where('reviewable_type', '=', 'blog_post')->get();
$review = new Review();
$review->reviewable_type = 'blog_post';
if ($review->reviewable_type === 'blog_post') {
Relation::getMorphedModel('blog_post');
}
}


Expand Down
3 changes: 3 additions & 0 deletions examples/laravel/app/Models/Review.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
#[UsePolicy(ReviewModerationPolicy::class)]
class Review extends Model
{
/** @var array<string, string> */
protected $casts = ['reviewable_type' => 'string'];

public function getTitle(): string { return ''; }
public function getRating(): int { return 0; }

Expand Down
20 changes: 20 additions & 0 deletions examples/laravel/assertions.php
Original file line number Diff line number Diff line change
Expand Up @@ -1468,6 +1468,26 @@ public function toArray(): array

\Illuminate\Container\Container::setInstance($previousContainer);

// ─── Morph aliases in column comparisons ────────────────────────────────────

$previousMorphMap = \Illuminate\Database\Eloquent\Relations\Relation::morphMap();
\Illuminate\Database\Eloquent\Relations\Relation::morphMap([
'blog_post' => \App\Models\BlogPost::class,
]);
$review = new \App\Models\Review();
$review->reviewable()->associate(new \App\Models\BlogPost());
check('Review declares its morph type column', $review->reviewable()->getMorphType() === 'reviewable_type');
check('An associated review stores the mapped alias', $review->reviewable_type === 'blog_post');
check(
'The column alias resolves to BlogPost',
\Illuminate\Database\Eloquent\Relations\Relation::getMorphedModel($review->reviewable_type) === \App\Models\BlogPost::class
);
check(
'A morph column query binds its literal alias',
\App\Models\Review::where('reviewable_type', 'blog_post')->getBindings() === ['blog_post']
);
\Illuminate\Database\Eloquent\Relations\Relation::morphMap($previousMorphMap, false);

// ─── Summary ────────────────────────────────────────────────────────────────

echo "\n";
Expand Down
1 change: 1 addition & 0 deletions src/backend/file_access.rs
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,7 @@ impl Backend {
self.symbols.uri_classes_index.write().remove(uri);
self.symbol_maps.write().remove(uri);
self.evict_typed_receiver_view_spans(uri);
self.morph_column_spans_cache.write().clear();
self.evict_reference_index_uri(uri);
self.file_imports.write().remove(uri);
self.resolved_names.write().remove(uri);
Expand Down
7 changes: 7 additions & 0 deletions src/completion/handler/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,13 @@ impl Backend {
// `try_laravel_string_key_completion`, which may trigger
// `ensure_workspace_indexed` → `update_ast` → write lock.
let is_laravel = self.resolved_class_cache.read().is_laravel();
if is_laravel
&& matches!(string_ctx, StringContext::InStringLiteral)
&& let Some(response) =
self.try_morph_column_completion(&uri, &content, position)
{
return Ok(Some(response));
}
if is_laravel
&& matches!(
string_ctx,
Expand Down
Loading
Loading