Skip to content
Merged
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
8 changes: 8 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,11 @@ harness = false
[[bench]]
name = "references"
harness = false

[[bench]]
name = "laravel_completion"
harness = false

[[bench]]
name = "custom_builder"
harness = false
121 changes: 121 additions & 0 deletions benches/custom_builder.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
use criterion::{Criterion, black_box, criterion_group, criterion_main};
use phpantom_lsp::Backend;
use std::collections::HashMap;
use tower_lsp::LanguageServer;
use tower_lsp::lsp_types::*;

fn rt() -> tokio::runtime::Runtime {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
}

fn leak_str(s: String) -> &'static str {
Box::leak(s.into_boxed_str())
}

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(): CustomBuilder {} }");
stubs.insert("Illuminate\\Database\\Eloquent\\Builder", "<?php namespace Illuminate\\Database\\Eloquent; class Builder { public function where($column): self {} }");

let mut user_src = String::from(
"<?php namespace App\\Models; class User extends \\Illuminate\\Database\\Eloquent\\Model {\n",
);
for i in 0..200 {
user_src.push_str(&format!(
" public function scopeActive{}($query) {{}}\n",
i
));
user_src.push_str(&format!(" public $prop{};\n", i));
}
user_src.push('}');
stubs.insert("App\\Models\\User", leak_str(user_src));

// Deep inheritance for the builder
for i in 0..10 {
let parent = if i == 0 {
"Illuminate\\Database\\Eloquent\\Builder"
} else {
leak_str(format!("App\\QueryBuilders\\BaseBuilder{}", i - 1))
};
let current = leak_str(format!("App\\QueryBuilders\\BaseBuilder{}", i));
let src = leak_str(format!(
"<?php namespace App\\QueryBuilders; class BaseBuilder{} extends {} {{ public function m{}(): void {{}} }}",
i, parent, i
));
stubs.insert(current, src);
}

stubs.insert("App\\QueryBuilders\\CustomBuilder", "<?php namespace App\\QueryBuilders; use App\\QueryBuilders\\BaseBuilder9; class CustomBuilder extends BaseBuilder9 { public function customMethod(): void {} }");

Backend::new_test_with_stubs(stubs)
}

async fn open_file(backend: &Backend, uri_str: &str, content: &str) -> Url {
let uri = Url::parse(uri_str).unwrap();
let params = DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: content.to_string(),
},
};
backend.did_open(params).await;
uri
}

fn generate_source() -> String {
r#"<?php
namespace App\Models;
use App\QueryBuilders\CustomBuilder;

/** @var CustomBuilder<\App\Models\User> $query */
$query->wher
"#
.to_string()
}

fn bench_custom_builder_completion(c: &mut Criterion) {
let runtime = rt();
let backend = runtime.block_on(setup_laravel_backend());
let source = generate_source();
let uri = runtime.block_on(open_file(&backend, "file:///app/Models/User.php", &source));
let lines: Vec<&str> = source.lines().collect();
let line = lines.len() as u32 - 1;
let last_line = lines.last().unwrap();
let col = last_line.len() as u32;

let mut group = c.benchmark_group("custom_builder_completion");

group.bench_function("custom_builder_deep_inheritance", |b| {
b.iter(|| {
runtime.block_on(async {
backend.clear_completion_cache();
let params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri: uri.clone() },
position: Position {
line,
character: col,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: Some(CompletionContext {
trigger_kind: CompletionTriggerKind::INVOKED,
trigger_character: None,
}),
};
let _ = black_box(backend.completion(params).await);
})
})
});

group.finish();
}

criterion_group!(benches, bench_custom_builder_completion);
criterion_main!(benches);
119 changes: 119 additions & 0 deletions benches/laravel_completion.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
use criterion::{Criterion, black_box, criterion_group, criterion_main};
use phpantom_lsp::Backend;
use std::collections::HashMap;
use tower_lsp::LanguageServer;
use tower_lsp::lsp_types::*;

fn rt() -> tokio::runtime::Runtime {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
}

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\\Builder", "<?php namespace Illuminate\\Database\\Eloquent; class Builder { public function where($column): self {} public function whereIn($column, $values): self {} public function orWhere($column): self {} }");

Backend::new_test_with_stubs(stubs)
}

async fn open_file(backend: &Backend, uri_str: &str, content: &str) -> Url {
let uri = Url::parse(uri_str).unwrap();
let params = DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: content.to_string(),
},
};
backend.did_open(params).await;
uri
}

fn generate_laravel_model_source() -> String {
r#"<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;

/**
* @property string $name
* @property string $email
*/
class User extends Model {}

$user = new User();
$user->wher
"#
.to_string()
}

fn bench_laravel_model_completion(c: &mut Criterion) {
let runtime = rt();
let backend = runtime.block_on(setup_laravel_backend());
let source = generate_laravel_model_source();
let uri = runtime.block_on(open_file(&backend, "file:///app/Models/User.php", &source));
let lines: Vec<&str> = source.lines().collect();
let line = lines.len() as u32 - 1;
let last_line = lines.last().unwrap();
let col = last_line.len() as u32; // After 'wher'

let mut group = c.benchmark_group("laravel_completion");

group.bench_function("model_where_prefix", |b| {
b.iter(|| {
runtime.block_on(async {
let params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri: uri.clone() },
position: Position {
line,
character: col,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: Some(CompletionContext {
trigger_kind: CompletionTriggerKind::INVOKED,
trigger_character: None,
}),
};
let _ = black_box(backend.completion(params).await);
})
})
});

// Simulate typing: Model::w -> Model::wh -> Model::whe -> Model::wher
group.bench_function("model_typing_sequence", |b| {
b.iter(|| {
runtime.block_on(async {
for i in 1..=4 {
let current_col = col - 4 + i;
let params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri: uri.clone() },
position: Position {
line,
character: current_col,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: Some(CompletionContext {
trigger_kind: CompletionTriggerKind::INVOKED,
trigger_character: None,
}),
};
let _ = black_box(backend.completion(params).await);
}
})
})
});

group.finish();
}

criterion_group!(benches, bench_laravel_model_completion);
criterion_main!(benches);
10 changes: 10 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **Laravel custom Eloquent builder support.** Models using the `#[UseEloquentBuilder]` attribute now have their custom builder's methods forwarded as static methods on the model. `query()`, `newQuery()`, and `newModelQuery()` return the custom builder type with correct generic model substitution. Contributed by @MingJen in https://github.com/AJenbo/phpantom_lsp/pull/118.

### Fixed

- **`throw new` completion no longer offers non-instantiable types.** Interfaces, abstract classes, traits, and enums are now filtered out, matching the behavior of `new` completion. The `throw new` path also now filters to Throwable descendants only.
- **Unified class name completion architecture.** `throw new` and `catch()` completion now use the same `build_class_name_completions` pipeline as `new`, `extends`, `implements`, etc. `throw new` uses a `ThrowNew` context (instantiable + Throwable) and `catch()`/`@throws` uses a `Catch` context (class or interface + Throwable). This gives both contexts the same affinity scoring, FQN shortening via use-map, namespace segment drill-down, deprecation flags, and consistent filtering. The separate `build_catch_class_name_completions` function has been removed.
- **Consolidated class completion passes.** The previous 5-pass architecture (use-map, same-namespace, fqn_uri_index, fqn_uri_index duplicate, stub_index) has been simplified to 2 passes (fqn_uri_index + stub_index) with an inline `classify` closure that determines tier (`'0'` use-imported, `'1'` same/sub-namespace, `'2'` everything else) per candidate. The redundant pass 4 (identical to pass 3) is eliminated, and tier assignment is now based on proximity checks rather than which data source produced the item.

### Changed

- **Improved LSP responsiveness.** File parsing (`update_ast`) and diagnostics now run in background tasks, preventing interactive requests (completion, hover) from being blocked by full-file parses during typing. Contributed by @MingJen in https://github.com/AJenbo/phpantom_lsp/pull/118.
- **Member completion caching.** Unfiltered member lists are cached per-target to speed up subsequent completions during keyword entry. Contributed by @MingJen in https://github.com/AJenbo/phpantom_lsp/pull/118.
- **Laravel startup performance.** Common Laravel builder classes are warmed in the background at startup to eliminate the first-access penalty on Eloquent completions. Contributed by @MingJen in https://github.com/AJenbo/phpantom_lsp/pull/118.

## [0.8.0] - 2026-05-14

### Added
Expand Down
7 changes: 3 additions & 4 deletions docs/todo.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ within the same impact tier.

> **Note:** F1 (Workspace symbol search), F2 (Document symbols), A8
> (Implement interface methods), A9 (Auto import), D1 (Unknown class
> diagnostic), and D3 (Unknown member diagnostic) were originally
> planned here but have already shipped.
> diagnostic), D3 (Unknown member diagnostic), and L4 (Custom Eloquent
> builders) were originally planned here but have already shipped.

## Sprint 6 — 1.0 release, editor plugins & type intelligence

Expand Down Expand Up @@ -151,8 +151,7 @@ unlikely to move the needle for most users.
| S4 | Named argument awareness in active parameter | Low-Medium | Medium |
| S5 | Language construct signature help and hover | Low | Low |
| | **[Laravel](todo/laravel.md)** | | |
| L4 | Custom Eloquent builders (`HasBuilder` / `#[UseEloquentBuilder]`) | Medium | Medium |
| L3 | `$dates` array (deprecated) | Low-Medium | Low |
| L3 | `$dates` array (deprecated) | Low-Medium | Low |
| L6 | Factory `has*`/`for*` relationship methods | Low-Medium | Medium |
| L7 | `$pivot` property on BelongsToMany | Medium | Medium-High |
| L8 | `withSum`/`withAvg`/`withMin`/`withMax` aggregate properties | Low-Medium | Medium-High |
Expand Down
39 changes: 0 additions & 39 deletions docs/todo/laravel.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,45 +238,6 @@ today and what is still missing.

---

#### L4. Custom Eloquent builders (`HasBuilder` / `#[UseEloquentBuilder]`)

**Impact: High · Effort: Medium**

Custom builders are the recommended pattern for complex query scoping
in modern Laravel. Without this, users get zero completions for
builder-specific methods via static model calls.

Laravel 11+ introduced the `HasBuilder` trait and
`#[UseEloquentBuilder(UserBuilder::class)]` attribute to let models
declare a custom builder class. When present, `User::query()` and
all static builder-forwarded calls should resolve to the custom
builder instead of the base `Illuminate\Database\Eloquent\Builder`.

```php
/** @extends Builder<User> */
class UserBuilder extends Builder {
/** @return $this */
public function active(): static { ... }
}

class User extends Model {
/** @use HasBuilder<UserBuilder> */
use HasBuilder;
}

User::query()->active()->get(); // active() should resolve on UserBuilder
```

Larastan handles this via `BuilderHelper::determineBuilderName()`,
which inspects `newEloquentBuilder()`'s return type or the
`#[UseEloquentBuilder]` attribute to find the custom builder class.

**Where to change:** In `build_builder_forwarded_methods`, before
loading the standard `Eloquent\Builder`, check whether the model
declares a custom builder via `@use HasBuilder<X>` in `use_generics`
or a `newEloquentBuilder()` method with a non-default return type.
If found, load and resolve that builder class instead.

#### L5. `abort_if`/`abort_unless` type narrowing

**Impact: High · Effort: Medium**
Expand Down
2 changes: 2 additions & 0 deletions examples/laravel/app/Models/Baker.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Attributes\UseEloquentBuilder;

#[UseEloquentBuilder(BakerBuilder::class)]
class Baker extends Model
{
public function getName(): string { return ''; }
Expand Down
20 changes: 20 additions & 0 deletions examples/laravel/app/Models/BakerBuilder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Builder;

/**
* @template TModel of \App\Models\Baker
* @extends Builder<TModel>
*/
class BakerBuilder extends Builder
{
/**
* @return $this
*/
public function active()
{
return $this->where('active', true);
}
}
Loading
Loading