Skip to content

Commit 944ea44

Browse files
committed
feat(laravel): support custom Eloquent builders and optimize references
- Implement support for #[UseEloquentBuilder] and HasBuilder trait. - Support inherited custom builder attributes by walking the Model parent chain. - Fix Go-to-Definition and Completion for forwarded methods on inherited custom builders. - Fix 'Find References' for forwarded builder methods by bridging Model/Builder hierarchies. - Improve reference accuracy by scoping hierarchy 'walk-down' to classes defining the member. - Relax is_static check for Laravel-related member references. - Optimize LSP responsiveness with background parsing and completion caching. - Add comprehensive integration tests for Laravel custom builder resolution and references.
1 parent 6c35273 commit 944ea44

26 files changed

Lines changed: 2748 additions & 234 deletions

Cargo.toml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,11 @@ harness = false
5959
[[bench]]
6060
name = "references"
6161
harness = false
62+
63+
[[bench]]
64+
name = "laravel_completion"
65+
harness = false
66+
67+
[[bench]]
68+
name = "custom_builder"
69+
harness = false

benches/custom_builder.rs

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
use criterion::{Criterion, black_box, criterion_group, criterion_main};
2+
use phpantom_lsp::Backend;
3+
use std::collections::HashMap;
4+
use tower_lsp::LanguageServer;
5+
use tower_lsp::lsp_types::*;
6+
7+
fn rt() -> tokio::runtime::Runtime {
8+
tokio::runtime::Builder::new_current_thread()
9+
.enable_all()
10+
.build()
11+
.unwrap()
12+
}
13+
14+
fn leak_str(s: String) -> &'static str {
15+
Box::leak(s.into_boxed_str())
16+
}
17+
18+
async fn setup_laravel_backend() -> Backend {
19+
let mut stubs = HashMap::new();
20+
stubs.insert("Illuminate\\Database\\Eloquent\\Model", "<?php namespace Illuminate\\Database\\Eloquent; class Model { public static function query(): CustomBuilder {} }");
21+
stubs.insert("Illuminate\\Database\\Eloquent\\Builder", "<?php namespace Illuminate\\Database\\Eloquent; class Builder { public function where($column): self {} }");
22+
23+
let mut user_src = String::from(
24+
"<?php namespace App\\Models; class User extends \\Illuminate\\Database\\Eloquent\\Model {\n",
25+
);
26+
for i in 0..200 {
27+
user_src.push_str(&format!(
28+
" public function scopeActive{}($query) {{}}\n",
29+
i
30+
));
31+
user_src.push_str(&format!(" public $prop{};\n", i));
32+
}
33+
user_src.push('}');
34+
stubs.insert("App\\Models\\User", leak_str(user_src));
35+
36+
// Deep inheritance for the builder
37+
for i in 0..10 {
38+
let parent = if i == 0 {
39+
"Illuminate\\Database\\Eloquent\\Builder"
40+
} else {
41+
leak_str(format!("App\\QueryBuilders\\BaseBuilder{}", i - 1))
42+
};
43+
let current = leak_str(format!("App\\QueryBuilders\\BaseBuilder{}", i));
44+
let src = leak_str(format!(
45+
"<?php namespace App\\QueryBuilders; class BaseBuilder{} extends {} {{ public function m{}(): void {{}} }}",
46+
i, parent, i
47+
));
48+
stubs.insert(current, src);
49+
}
50+
51+
stubs.insert("App\\QueryBuilders\\CustomBuilder", "<?php namespace App\\QueryBuilders; use App\\QueryBuilders\\BaseBuilder9; class CustomBuilder extends BaseBuilder9 { public function customMethod(): void {} }");
52+
53+
Backend::new_test_with_stubs(stubs)
54+
}
55+
56+
async fn open_file(backend: &Backend, uri_str: &str, content: &str) -> Url {
57+
let uri = Url::parse(uri_str).unwrap();
58+
let params = DidOpenTextDocumentParams {
59+
text_document: TextDocumentItem {
60+
uri: uri.clone(),
61+
language_id: "php".to_string(),
62+
version: 1,
63+
text: content.to_string(),
64+
},
65+
};
66+
backend.did_open(params).await;
67+
uri
68+
}
69+
70+
fn generate_source() -> String {
71+
r#"<?php
72+
namespace App\Models;
73+
use App\QueryBuilders\CustomBuilder;
74+
75+
/** @var CustomBuilder<\App\Models\User> $query */
76+
$query->wher
77+
"#
78+
.to_string()
79+
}
80+
81+
fn bench_custom_builder_completion(c: &mut Criterion) {
82+
let runtime = rt();
83+
let backend = runtime.block_on(setup_laravel_backend());
84+
let source = generate_source();
85+
let uri = runtime.block_on(open_file(&backend, "file:///app/Models/User.php", &source));
86+
let lines: Vec<&str> = source.lines().collect();
87+
let line = lines.len() as u32 - 1;
88+
let last_line = lines.last().unwrap();
89+
let col = last_line.len() as u32;
90+
91+
let mut group = c.benchmark_group("custom_builder_completion");
92+
93+
group.bench_function("custom_builder_deep_inheritance", |b| {
94+
b.iter(|| {
95+
runtime.block_on(async {
96+
backend.clear_completion_cache();
97+
let params = CompletionParams {
98+
text_document_position: TextDocumentPositionParams {
99+
text_document: TextDocumentIdentifier { uri: uri.clone() },
100+
position: Position {
101+
line,
102+
character: col,
103+
},
104+
},
105+
work_done_progress_params: WorkDoneProgressParams::default(),
106+
partial_result_params: PartialResultParams::default(),
107+
context: Some(CompletionContext {
108+
trigger_kind: CompletionTriggerKind::INVOKED,
109+
trigger_character: None,
110+
}),
111+
};
112+
let _ = black_box(backend.completion(params).await);
113+
})
114+
})
115+
});
116+
117+
group.finish();
118+
}
119+
120+
criterion_group!(benches, bench_custom_builder_completion);
121+
criterion_main!(benches);

benches/laravel_completion.rs

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
use criterion::{Criterion, black_box, criterion_group, criterion_main};
2+
use phpantom_lsp::Backend;
3+
use std::collections::HashMap;
4+
use tower_lsp::LanguageServer;
5+
use tower_lsp::lsp_types::*;
6+
7+
fn rt() -> tokio::runtime::Runtime {
8+
tokio::runtime::Builder::new_current_thread()
9+
.enable_all()
10+
.build()
11+
.unwrap()
12+
}
13+
14+
async fn setup_laravel_backend() -> Backend {
15+
let mut stubs = HashMap::new();
16+
stubs.insert("Illuminate\\Database\\Eloquent\\Model", "<?php namespace Illuminate\\Database\\Eloquent; class Model { public static function query(): Builder {} }");
17+
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 {} }");
18+
19+
Backend::new_test_with_stubs(stubs)
20+
}
21+
22+
async fn open_file(backend: &Backend, uri_str: &str, content: &str) -> Url {
23+
let uri = Url::parse(uri_str).unwrap();
24+
let params = DidOpenTextDocumentParams {
25+
text_document: TextDocumentItem {
26+
uri: uri.clone(),
27+
language_id: "php".to_string(),
28+
version: 1,
29+
text: content.to_string(),
30+
},
31+
};
32+
backend.did_open(params).await;
33+
uri
34+
}
35+
36+
fn generate_laravel_model_source() -> String {
37+
r#"<?php
38+
namespace App\Models;
39+
use Illuminate\Database\Eloquent\Model;
40+
41+
/**
42+
* @property string $name
43+
* @property string $email
44+
*/
45+
class User extends Model {}
46+
47+
$user = new User();
48+
$user->wher
49+
"#
50+
.to_string()
51+
}
52+
53+
fn bench_laravel_model_completion(c: &mut Criterion) {
54+
let runtime = rt();
55+
let backend = runtime.block_on(setup_laravel_backend());
56+
let source = generate_laravel_model_source();
57+
let uri = runtime.block_on(open_file(&backend, "file:///app/Models/User.php", &source));
58+
let lines: Vec<&str> = source.lines().collect();
59+
let line = lines.len() as u32 - 1;
60+
let last_line = lines.last().unwrap();
61+
let col = last_line.len() as u32; // After 'wher'
62+
63+
let mut group = c.benchmark_group("laravel_completion");
64+
65+
group.bench_function("model_where_prefix", |b| {
66+
b.iter(|| {
67+
runtime.block_on(async {
68+
let params = CompletionParams {
69+
text_document_position: TextDocumentPositionParams {
70+
text_document: TextDocumentIdentifier { uri: uri.clone() },
71+
position: Position {
72+
line,
73+
character: col,
74+
},
75+
},
76+
work_done_progress_params: WorkDoneProgressParams::default(),
77+
partial_result_params: PartialResultParams::default(),
78+
context: Some(CompletionContext {
79+
trigger_kind: CompletionTriggerKind::INVOKED,
80+
trigger_character: None,
81+
}),
82+
};
83+
let _ = black_box(backend.completion(params).await);
84+
})
85+
})
86+
});
87+
88+
// Simulate typing: Model::w -> Model::wh -> Model::whe -> Model::wher
89+
group.bench_function("model_typing_sequence", |b| {
90+
b.iter(|| {
91+
runtime.block_on(async {
92+
for i in 1..=4 {
93+
let current_col = col - 4 + i;
94+
let params = CompletionParams {
95+
text_document_position: TextDocumentPositionParams {
96+
text_document: TextDocumentIdentifier { uri: uri.clone() },
97+
position: Position {
98+
line,
99+
character: current_col,
100+
},
101+
},
102+
work_done_progress_params: WorkDoneProgressParams::default(),
103+
partial_result_params: PartialResultParams::default(),
104+
context: Some(CompletionContext {
105+
trigger_kind: CompletionTriggerKind::INVOKED,
106+
trigger_character: None,
107+
}),
108+
};
109+
let _ = black_box(backend.completion(params).await);
110+
}
111+
})
112+
})
113+
});
114+
115+
group.finish();
116+
}
117+
118+
criterion_group!(benches, bench_laravel_model_completion);
119+
criterion_main!(benches);

examples/laravel/app/Models/Baker.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
namespace App\Models;
44

55
use Illuminate\Database\Eloquent\Model;
6+
use Illuminate\Database\Eloquent\Attributes\UseEloquentBuilder;
67

8+
#[UseEloquentBuilder(BakerBuilder::class)]
79
class Baker extends Model
810
{
911
public function getName(): string { return ''; }
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?php
2+
3+
namespace App\Models;
4+
5+
use Illuminate\Database\Eloquent\Builder;
6+
7+
/**
8+
* @template TModel of \App\Models\Baker
9+
* @extends Builder<TModel>
10+
*/
11+
class BakerBuilder extends Builder
12+
{
13+
/**
14+
* @return $this
15+
*/
16+
public function active()
17+
{
18+
return $this->where('active', true);
19+
}
20+
}

src/completion/builder.rs

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ use std::sync::Arc;
2222
use tower_lsp::lsp_types::*;
2323

2424
use super::resolve::CompletionItemData;
25+
use crate::hover::{MemberKindForOrigin, find_declaring_class};
2526
use crate::types::Visibility;
2627
use crate::types::*;
2728

@@ -658,7 +659,8 @@ pub(crate) fn build_union_completion_items(
658659
uri,
659660
);
660661

661-
for item in items {
662+
for mut item in items {
663+
apply_declaring_class_label(&mut item, &merged, class_loader);
662664
if let Some(existing) = all_items
663665
.iter_mut()
664666
.find(|existing| existing.label == item.label)
@@ -679,6 +681,47 @@ pub(crate) fn build_union_completion_items(
679681
merge_union_completion_items(all_items, occurrence_count, num_candidates)
680682
}
681683

684+
fn apply_declaring_class_label(
685+
item: &mut CompletionItem,
686+
owner: &ClassInfo,
687+
class_loader: &dyn Fn(&str) -> Option<Arc<ClassInfo>>,
688+
) {
689+
let Some(data_value) = item.data.as_ref() else {
690+
return;
691+
};
692+
let Ok(mut data) = serde_json::from_value::<CompletionItemData>(data_value.clone()) else {
693+
return;
694+
};
695+
let member_kind = match data.kind.as_str() {
696+
"method" => MemberKindForOrigin::Method,
697+
"property" => MemberKindForOrigin::Property,
698+
"constant" => MemberKindForOrigin::Constant,
699+
_ => return,
700+
};
701+
702+
let declaring = find_declaring_class(owner, &data.member_name, &member_kind, class_loader);
703+
let declaring_name = declaring.name.to_string();
704+
if declaring_name == data.class_name {
705+
return;
706+
}
707+
708+
data.class_name = declaring_name.clone();
709+
data.extra_class_names.clear();
710+
if let Ok(value) = serde_json::to_value(&data) {
711+
item.data = Some(value);
712+
}
713+
714+
let description = Some(display_class_name(&declaring_name).to_string());
715+
if let Some(ref mut label_details) = item.label_details {
716+
label_details.description = description;
717+
} else {
718+
item.label_details = Some(CompletionItemLabelDetails {
719+
detail: None,
720+
description,
721+
});
722+
}
723+
}
724+
682725
/// Merge the class name from a new item's `data` into the existing item's
683726
/// `data.extra_class_names` so that `completionItem/resolve` can iterate
684727
/// all union branches when building hover documentation.

0 commit comments

Comments
 (0)