use crate::common::{create_psr4_workspace, create_test_backend};
use tower_lsp::LanguageServer;
use tower_lsp::lsp_types::*;
const COMPOSER_JSON: &str = r#"{
"autoload": {
"psr-4": {
"App\\Models\\": "src/Models/",
"App\\Casts\\": "src/Casts/",
"App\\Collections\\": "src/Collections/",
"App\\Concerns\\": "src/Concerns/",
"Database\\Factories\\": "database/factories/",
"Illuminate\\Support\\": "vendor/illuminate/Support/",
"Illuminate\\Contracts\\Database\\Eloquent\\": "vendor/illuminate/Contracts/",
"Illuminate\\Database\\Eloquent\\": "vendor/illuminate/Eloquent/",
"Illuminate\\Database\\Eloquent\\Attributes\\": "vendor/illuminate/Eloquent/Attributes/",
"Illuminate\\Database\\Eloquent\\Factories\\": "vendor/illuminate/Eloquent/Factories/",
"Illuminate\\Database\\Eloquent\\Relations\\": "vendor/illuminate/Eloquent/Relations/",
"Illuminate\\Database\\Query\\": "vendor/illuminate/Query/",
"Illuminate\\Database\\Concerns\\": "vendor/illuminate/Concerns/"
}
}
}"#;
const MODEL_PHP: &str = "\
<?php
namespace Illuminate\\Database\\Eloquent;
class Model {
/** @return \\Illuminate\\Database\\Eloquent\\Builder<static> */
public static function with(mixed $relations): Builder { return new Builder(); }
}
";
const COLLECTION_PHP: &str = "\
<?php
namespace Illuminate\\Database\\Eloquent;
/**
* @template TKey of array-key
* @template TModel
*/
class Collection {
/** @return int */
public function count(): int { return 0; }
/** @return TModel|null */
public function first(): mixed { return null; }
/** @return array<TKey, TModel> */
public function all(): array { return []; }
}
";
const HAS_MANY_PHP: &str = "\
<?php
namespace Illuminate\\Database\\Eloquent\\Relations;
class HasMany {}
";
const HAS_ONE_PHP: &str = "\
<?php
namespace Illuminate\\Database\\Eloquent\\Relations;
class HasOne {}
";
const BELONGS_TO_PHP: &str = "\
<?php
namespace Illuminate\\Database\\Eloquent\\Relations;
class BelongsTo {}
";
const BELONGS_TO_MANY_PHP: &str = "\
<?php
namespace Illuminate\\Database\\Eloquent\\Relations;
class BelongsToMany {}
";
const MORPH_TO_PHP: &str = "\
<?php
namespace Illuminate\\Database\\Eloquent\\Relations;
class MorphTo {}
";
const MORPH_ONE_PHP: &str = "\
<?php
namespace Illuminate\\Database\\Eloquent\\Relations;
class MorphOne {}
";
const MORPH_MANY_PHP: &str = "\
<?php
namespace Illuminate\\Database\\Eloquent\\Relations;
class MorphMany {}
";
const MORPH_TO_MANY_PHP: &str = "\
<?php
namespace Illuminate\\Database\\Eloquent\\Relations;
class MorphToMany {}
";
const HAS_MANY_THROUGH_PHP: &str = "\
<?php
namespace Illuminate\\Database\\Eloquent\\Relations;
class HasManyThrough {}
";
const BUILDER_PHP: &str = "\
<?php
namespace Illuminate\\Database\\Eloquent;
use Illuminate\\Database\\Concerns\\BuildsQueries;
/**
* @template TModel of \\Illuminate\\Database\\Eloquent\\Model
* @mixin \\Illuminate\\Database\\Query\\Builder
*/
class Builder {
/** @use BuildsQueries<TModel> */
use BuildsQueries;
/** @return static */
public function where(string $column, mixed $operator = null, mixed $value = null): static { return $this; }
/** @return static */
public function orderBy(string $column, string $direction = 'asc'): static { return $this; }
/** @return \\Illuminate\\Database\\Eloquent\\Collection<int, TModel> */
public function get(): Collection { return new Collection(); }
/** @return TModel|\\Illuminate\\Database\\Eloquent\\Collection<int, TModel>|null */
public function find(mixed $id): mixed { return null; }
/**
* @param mixed $id
* @return ($id is (\\Illuminate\\Contracts\\Support\\Arrayable<array-key, mixed>|array<mixed>) ? \\Illuminate\\Database\\Eloquent\\Collection<int, TModel> : TModel)
*/
public function findOrFail(mixed $id, array $columns = ['*']): mixed { return null; }
/** @return static */
public function limit(int $value): static { return $this; }
/** @return bool */
public function exists(): bool { return false; }
/** @return string */
public function toSql(): string { return ''; }
/**
* @param string $relation
* @param (\\Closure(\\Illuminate\\Database\\Eloquent\\Builder<TModel>): mixed)|null $callback
* @return static
*/
public function whereHas(string $relation, ?\\Closure $callback = null): static { return $this; }
/**
* @param string $relation
* @param (\\Closure(\\Illuminate\\Database\\Eloquent\\Builder<TModel>): mixed)|null $callback
* @return static
*/
public function orWhereHas(string $relation, ?\\Closure $callback = null): static { return $this; }
/**
* @param string $relation
* @param (\\Closure(\\Illuminate\\Database\\Eloquent\\Builder<TModel>): mixed)|null $callback
* @return static
*/
public function whereDoesntHave(string $relation, ?\\Closure $callback = null): static { return $this; }
/**
* @param string $relation
* @param (\\Closure(\\Illuminate\\Database\\Eloquent\\Builder<TModel>): mixed)|null $callback
* @return static
*/
public function withWhereHas(string $relation, ?\\Closure $callback = null): static { return $this; }
/**
* @param array<array-key, array|(\\Closure(\\Illuminate\\Database\\Eloquent\\Relations\\Relation): mixed)|string>|string $relations
* @param (\\Closure(\\Illuminate\\Database\\Eloquent\\Relations\\Relation): mixed)|string|null $callback
* @return static
*/
public function with(mixed $relations, mixed $callback = null): static { return $this; }
}
";
const QUERY_BUILDER_PHP: &str = "\
<?php
namespace Illuminate\\Database\\Query;
class Builder {
/** @return static */
public function whereIn(string $column, array $values): static { return $this; }
/** @return static */
public function whereNested(\\Closure $callback, string $boolean = 'and'): static { return $this; }
/** @return static */
public function groupBy(string ...$groups): static { return $this; }
/** @return static */
public function having(string $column, mixed $operator = null, mixed $value = null): static { return $this; }
}
";
const BUILDS_QUERIES_PHP: &str = "\
<?php
namespace Illuminate\\Database\\Concerns;
/**
* @template TValue
*/
trait BuildsQueries {
/** @return TValue|null */
public function first(): mixed { return null; }
/** @return TValue */
public function firstOrFail(): mixed { return null; }
/** @return TValue|null */
public function sole(): mixed { return null; }
/**
* @param callable(\\Illuminate\\Support\\Collection<int, TValue>, int): mixed $callback
* @return bool
*/
public function chunk(int $count, callable $callback): bool { return true; }
}
";
const SUPPORT_COLLECTION_PHP: &str = "\
<?php
namespace Illuminate\\Support;
/**
* @template TKey of array-key
* @template TValue
*/
class Collection {
/** @return int */
public function count(): int { return 0; }
/** @return TValue|null */
public function first(): mixed { return null; }
/** @return array<TKey, TValue> */
public function all(): array { return []; }
/**
* @param callable(TValue, TKey): mixed $callback
* @return static
*/
public function each(callable $callback): static { return $this; }
/**
* @template TMapValue
* @param callable(TValue, TKey): TMapValue $callback
* @return static<TKey, TMapValue>
*/
public function map(callable $callback): static { return $this; }
}
";
const RELATION_PHP: &str = "\
<?php
namespace Illuminate\\Database\\Eloquent\\Relations;
/**
* @template TRelated of \\Illuminate\\Database\\Eloquent\\Model
* @template TDeclaringModel of \\Illuminate\\Database\\Eloquent\\Model
* @template TResult
*/
class Relation {
/** @return static */
public function where(string $column, mixed $operator = null, mixed $value = null): static { return $this; }
/** @return static */
public function orderBy(string $column, string $direction = 'asc'): static { return $this; }
}
";
const COLLECTED_BY_PHP: &str = "\
<?php
namespace Illuminate\\Database\\Eloquent\\Attributes;
class CollectedBy { public function __construct(string $collectionClass) {} }
";
const SCOPE_ATTR_PHP: &str = "\
<?php
namespace Illuminate\\Database\\Eloquent\\Attributes;
class Scope {}
";
const HAS_COLLECTION_PHP: &str = "\
<?php
namespace Illuminate\\Database\\Eloquent;
/**
* @template TCollection of \\Illuminate\\Database\\Eloquent\\Collection
*/
trait HasCollection {}
";
const HAS_FACTORY_PHP: &str = "\
<?php
namespace Illuminate\\Database\\Eloquent\\Factories;
/**
* @template TFactory of Factory
*/
trait HasFactory {
/** @return TFactory */
public static function factory() {}
}
";
const FACTORY_PHP: &str = "\
<?php
namespace Illuminate\\Database\\Eloquent\\Factories;
/**
* @template TModel of \\Illuminate\\Database\\Eloquent\\Model
*/
class Factory {
/** @return TModel */
public function create(array $attributes = []) {}
/** @return TModel */
public function make(array $attributes = []) {}
/** @return static */
public function count(int $count): static { return $this; }
/** @return static */
public function state(array $state): static { return $this; }
}
";
const CASTS_ATTRIBUTES_PHP: &str = "\
<?php
namespace Illuminate\\Contracts\\Database\\Eloquent;
/**
* @template TGet
* @template TSet
*/
interface CastsAttributes
{
/**
* @param \\Illuminate\\Database\\Eloquent\\Model $model
* @param string $key
* @param mixed $value
* @param array<string, mixed> $attributes
* @return TGet|null
*/
public function get($model, string $key, mixed $value, array $attributes): mixed;
}
";
fn framework_stubs() -> Vec<(&'static str, &'static str)> {
vec![
("vendor/illuminate/Eloquent/Model.php", MODEL_PHP),
(
"vendor/illuminate/Concerns/BuildsQueries.php",
BUILDS_QUERIES_PHP,
),
("vendor/illuminate/Eloquent/Collection.php", COLLECTION_PHP),
("vendor/illuminate/Eloquent/Builder.php", BUILDER_PHP),
("vendor/illuminate/Query/Builder.php", QUERY_BUILDER_PHP),
(
"vendor/illuminate/Eloquent/Relations/HasMany.php",
HAS_MANY_PHP,
),
(
"vendor/illuminate/Eloquent/Relations/HasOne.php",
HAS_ONE_PHP,
),
(
"vendor/illuminate/Eloquent/Relations/BelongsTo.php",
BELONGS_TO_PHP,
),
(
"vendor/illuminate/Eloquent/Relations/BelongsToMany.php",
BELONGS_TO_MANY_PHP,
),
(
"vendor/illuminate/Eloquent/Relations/MorphTo.php",
MORPH_TO_PHP,
),
(
"vendor/illuminate/Eloquent/Relations/MorphOne.php",
MORPH_ONE_PHP,
),
(
"vendor/illuminate/Eloquent/Relations/MorphMany.php",
MORPH_MANY_PHP,
),
(
"vendor/illuminate/Eloquent/Relations/MorphToMany.php",
MORPH_TO_MANY_PHP,
),
(
"vendor/illuminate/Eloquent/Relations/HasManyThrough.php",
HAS_MANY_THROUGH_PHP,
),
(
"vendor/illuminate/Eloquent/Attributes/CollectedBy.php",
COLLECTED_BY_PHP,
),
(
"vendor/illuminate/Eloquent/Attributes/Scope.php",
SCOPE_ATTR_PHP,
),
(
"vendor/illuminate/Eloquent/HasCollection.php",
HAS_COLLECTION_PHP,
),
(
"vendor/illuminate/Eloquent/Factories/HasFactory.php",
HAS_FACTORY_PHP,
),
(
"vendor/illuminate/Eloquent/Factories/Factory.php",
FACTORY_PHP,
),
(
"vendor/illuminate/Support/Collection.php",
SUPPORT_COLLECTION_PHP,
),
(
"vendor/illuminate/Eloquent/Relations/Relation.php",
RELATION_PHP,
),
]
}
fn make_workspace(app_files: &[(&str, &str)]) -> (phpantom_lsp::Backend, tempfile::TempDir) {
let mut files: Vec<(&str, &str)> = framework_stubs();
files.extend_from_slice(app_files);
create_psr4_workspace(COMPOSER_JSON, &files)
}
async fn complete_at(
backend: &phpantom_lsp::Backend,
dir: &tempfile::TempDir,
relative_path: &str,
content: &str,
line: u32,
character: u32,
) -> Vec<CompletionItem> {
let uri = Url::from_file_path(dir.path().join(relative_path)).unwrap();
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: content.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position { line, character },
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
match result {
Some(CompletionResponse::Array(items)) => items,
Some(CompletionResponse::List(list)) => list.items,
_ => Vec::new(),
}
}
fn property_names(items: &[CompletionItem]) -> Vec<&str> {
items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::PROPERTY))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect()
}
fn method_names(items: &[CompletionItem]) -> Vec<&str> {
items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect()
}
#[tokio::test]
async fn test_has_many_relationship_produces_property() {
let post_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class Post extends Model {
public function getTitle(): string { return ''; }
}
";
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\HasMany;
class User extends Model {
/** @return HasMany<\\App\\Models\\Post, $this> */
public function posts(): HasMany { return $this->hasMany(Post::class); }
public function test() {
$user = new User();
$user->
}
}
";
let (backend, dir) = make_workspace(&[
("src/Models/Post.php", post_php),
("src/Models/User.php", user_php),
]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 9, 15).await;
let props = property_names(&items);
assert!(
props.contains(&"posts"),
"Should include synthesized 'posts' relationship property, got: {:?}",
props
);
let methods = method_names(&items);
assert!(
methods.contains(&"posts"),
"The relationship method itself should also appear, got: {:?}",
methods
);
}
#[tokio::test]
async fn test_has_one_relationship_produces_property() {
let profile_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class Profile extends Model {
public function getBio(): string { return ''; }
}
";
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\HasOne;
class User extends Model {
/** @return HasOne<\\App\\Models\\Profile, $this> */
public function profile(): HasOne { return $this->hasOne(Profile::class); }
public function test() {
$user = new User();
$user->
}
}
";
let (backend, dir) = make_workspace(&[
("src/Models/Profile.php", profile_php),
("src/Models/User.php", user_php),
]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 9, 15).await;
let props = property_names(&items);
assert!(
props.contains(&"profile"),
"Should include synthesized 'profile' property, got: {:?}",
props
);
}
#[tokio::test]
async fn test_belongs_to_relationship_produces_property() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
public function getEmail(): string { return ''; }
}
";
let post_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;
class Post extends Model {
/** @return BelongsTo<\\App\\Models\\User, $this> */
public function author(): BelongsTo { return $this->belongsTo(User::class); }
public function test() {
$post = new Post();
$post->
}
}
";
let (backend, dir) = make_workspace(&[
("src/Models/User.php", user_php),
("src/Models/Post.php", post_php),
]);
let items = complete_at(&backend, &dir, "src/Models/Post.php", post_php, 9, 15).await;
let props = property_names(&items);
assert!(
props.contains(&"author"),
"Should include synthesized 'author' property, got: {:?}",
props
);
}
#[tokio::test]
async fn test_morph_to_relationship_produces_property() {
let comment_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\MorphTo;
class Comment extends Model {
/** @return MorphTo */
public function commentable(): MorphTo { return $this->morphTo(); }
public function test() {
$comment = new Comment();
$comment->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/Comment.php", comment_php)]);
let items = complete_at(&backend, &dir, "src/Models/Comment.php", comment_php, 9, 19).await;
let props = property_names(&items);
assert!(
props.contains(&"commentable"),
"Should include synthesized 'commentable' property, got: {:?}",
props
);
}
#[tokio::test]
async fn test_multiple_relationships_all_produce_properties() {
let post_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class Post extends Model {}
";
let profile_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class Profile extends Model {}
";
let role_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class Role extends Model {}
";
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\HasMany;
use Illuminate\\Database\\Eloquent\\Relations\\HasOne;
use Illuminate\\Database\\Eloquent\\Relations\\BelongsToMany;
class User extends Model {
/** @return HasMany<\\App\\Models\\Post, $this> */
public function posts(): HasMany { return $this->hasMany(Post::class); }
/** @return HasOne<\\App\\Models\\Profile, $this> */
public function profile(): HasOne { return $this->hasOne(Profile::class); }
/** @return BelongsToMany<\\App\\Models\\Role, $this> */
public function roles(): BelongsToMany { return $this->belongsToMany(Role::class); }
public function getFullName(): string { return ''; }
public function test() {
$user = new User();
$user->
}
}
";
let (backend, dir) = make_workspace(&[
("src/Models/Post.php", post_php),
("src/Models/Profile.php", profile_php),
("src/Models/Role.php", role_php),
("src/Models/User.php", user_php),
]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 16, 15).await;
let props = property_names(&items);
assert!(
props.contains(&"posts"),
"Should include 'posts' property, got: {:?}",
props
);
assert!(
props.contains(&"profile"),
"Should include 'profile' property, got: {:?}",
props
);
assert!(
props.contains(&"roles"),
"Should include 'roles' property, got: {:?}",
props
);
assert!(
!props.contains(&"getFullName"),
"'getFullName' should not appear as a property, got: {:?}",
props
);
}
#[tokio::test]
async fn test_relationship_property_does_not_appear_for_non_models() {
let service_php = "\
<?php
namespace App\\Models;
class HasMany {}
class UserService {
/** @return HasMany */
public function posts(): HasMany { return new HasMany(); }
public function test() {
$svc = new UserService();
$svc->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/UserService.php", service_php)]);
let items = complete_at(
&backend,
&dir,
"src/Models/UserService.php",
service_php,
8,
14,
)
.await;
let props = property_names(&items);
assert!(
!props.contains(&"posts"),
"'posts' should NOT be synthesized on non-Model class, got: {:?}",
props
);
}
#[tokio::test]
async fn test_has_one_relationship_property_chains_to_related_class() {
let profile_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class Profile extends Model {
public function getBio(): string { return ''; }
public function getAvatar(): string { return ''; }
}
";
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\HasOne;
class User extends Model {
/** @return HasOne<\\App\\Models\\Profile, $this> */
public function profile(): HasOne { return $this->hasOne(Profile::class); }
public function test() {
$user = new User();
$user->profile->
}
}
";
let (backend, dir) = make_workspace(&[
("src/Models/Profile.php", profile_php),
("src/Models/User.php", user_php),
]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 9, 24).await;
let methods = method_names(&items);
assert!(
methods.contains(&"getBio"),
"Should chain through profile to Profile::getBio, got: {:?}",
methods
);
assert!(
methods.contains(&"getAvatar"),
"Should chain through profile to Profile::getAvatar, got: {:?}",
methods
);
}
#[tokio::test]
async fn test_this_arrow_shows_relationship_properties() {
let post_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class Post extends Model {}
";
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\HasMany;
class User extends Model {
/** @return HasMany<\\App\\Models\\Post, $this> */
public function posts(): HasMany { return $this->hasMany(Post::class); }
public function test() {
$this->
}
}
";
let (backend, dir) = make_workspace(&[
("src/Models/Post.php", post_php),
("src/Models/User.php", user_php),
]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 8, 15).await;
let props = property_names(&items);
assert!(
props.contains(&"posts"),
"Should include synthesized 'posts' property via $this->, got: {:?}",
props
);
}
#[tokio::test]
async fn test_laravel_provider_beats_phpdoc_property_tag() {
let post_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class Post extends Model {
public function getTitle(): string { return ''; }
}
";
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\HasMany;
/**
* @property array $posts
*/
class User extends Model {
/** @return HasMany<\\App\\Models\\Post, $this> */
public function posts(): HasMany { return $this->hasMany(Post::class); }
public function test() {
$user = new User();
$user->
}
}
";
let (backend, dir) = make_workspace(&[
("src/Models/Post.php", post_php),
("src/Models/User.php", user_php),
]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 12, 15).await;
let posts_props: Vec<&CompletionItem> = items
.iter()
.filter(|i| {
i.kind == Some(CompletionItemKind::PROPERTY)
&& i.filter_text.as_deref().unwrap_or(&i.label) == "posts"
})
.collect();
assert_eq!(
posts_props.len(),
1,
"Should have exactly one 'posts' property (Laravel provider wins over @property), got: {}",
posts_props.len()
);
}
#[tokio::test]
async fn test_relationship_from_trait_produces_property() {
let post_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class Post extends Model {}
";
let trait_php = "\
<?php
namespace App\\Concerns;
use Illuminate\\Database\\Eloquent\\Relations\\HasMany;
trait HasPosts {
/** @return HasMany<\\App\\Models\\Post, $this> */
public function posts(): HasMany { return $this->hasMany(\\App\\Models\\Post::class); }
}
";
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use App\\Concerns\\HasPosts;
class User extends Model {
use HasPosts;
public function test() {
$user = new User();
$user->
}
}
";
let (backend, dir) = make_workspace(&[
("src/Models/Post.php", post_php),
("src/Concerns/HasPosts.php", trait_php),
("src/Models/User.php", user_php),
]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 8, 15).await;
let props = property_names(&items);
assert!(
props.contains(&"posts"),
"Trait relationship method should produce virtual property, got: {:?}",
props
);
}
#[tokio::test]
async fn test_indirect_model_subclass_gets_relationship_properties() {
let base_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class BaseModel extends Model {}
";
let post_php = "\
<?php
namespace App\\Models;
class Post extends BaseModel {}
";
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Relations\\HasMany;
class User extends BaseModel {
/** @return HasMany<\\App\\Models\\Post, $this> */
public function posts(): HasMany { return $this->hasMany(Post::class); }
public function test() {
$user = new User();
$user->
}
}
";
let (backend, dir) = make_workspace(&[
("src/Models/BaseModel.php", base_php),
("src/Models/Post.php", post_php),
("src/Models/User.php", user_php),
]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 8, 15).await;
let props = property_names(&items);
assert!(
props.contains(&"posts"),
"Indirect Model subclass should still get relationship properties, got: {:?}",
props
);
}
#[tokio::test]
async fn test_fqn_relationship_return_type_produces_property() {
let post_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class Post extends Model {}
";
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
/** @return \\Illuminate\\Database\\Eloquent\\Relations\\HasMany<\\App\\Models\\Post, $this> */
public function posts(): \\Illuminate\\Database\\Eloquent\\Relations\\HasMany {
return $this->hasMany(Post::class);
}
public function test() {
$user = new User();
$user->
}
}
";
let (backend, dir) = make_workspace(&[
("src/Models/Post.php", post_php),
("src/Models/User.php", user_php),
]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 10, 15).await;
let props = property_names(&items);
assert!(
props.contains(&"posts"),
"FQN return type should still produce 'posts' property, got: {:?}",
props
);
}
#[tokio::test]
async fn test_morph_many_and_belongs_to_many_produce_properties() {
let comment_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class Comment extends Model {}
";
let role_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class Role extends Model {}
";
let tag_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class Tag extends Model {}
";
let deployment_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class Deployment extends Model {}
";
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\MorphMany;
use Illuminate\\Database\\Eloquent\\Relations\\BelongsToMany;
use Illuminate\\Database\\Eloquent\\Relations\\HasManyThrough;
use Illuminate\\Database\\Eloquent\\Relations\\MorphToMany;
class User extends Model {
/** @return MorphMany<\\App\\Models\\Comment, $this> */
public function comments(): MorphMany { return $this->morphMany(Comment::class, 'commentable'); }
/** @return BelongsToMany<\\App\\Models\\Role, $this> */
public function roles(): BelongsToMany { return $this->belongsToMany(Role::class); }
/** @return HasManyThrough<\\App\\Models\\Deployment, \\App\\Models\\User> */
public function deployments(): HasManyThrough { return $this->hasManyThrough(Deployment::class, User::class); }
/** @return MorphToMany<\\App\\Models\\Tag, $this> */
public function tags(): MorphToMany { return $this->morphToMany(Tag::class, 'taggable'); }
public function test() {
$user = new User();
$user->
}
}
";
let (backend, dir) = make_workspace(&[
("src/Models/Comment.php", comment_php),
("src/Models/Role.php", role_php),
("src/Models/Tag.php", tag_php),
("src/Models/Deployment.php", deployment_php),
("src/Models/User.php", user_php),
]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 18, 15).await;
let props = property_names(&items);
assert!(
props.contains(&"comments"),
"MorphMany should produce 'comments' property, got: {:?}",
props
);
assert!(
props.contains(&"roles"),
"BelongsToMany should produce 'roles' property, got: {:?}",
props
);
assert!(
props.contains(&"deployments"),
"HasManyThrough should produce 'deployments' property, got: {:?}",
props
);
assert!(
props.contains(&"tags"),
"MorphToMany should produce 'tags' property, got: {:?}",
props
);
}
#[tokio::test]
async fn test_morph_one_relationship_produces_property() {
let image_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class Image extends Model {
public function getUrl(): string { return ''; }
}
";
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\MorphOne;
class User extends Model {
/** @return MorphOne<\\App\\Models\\Image, $this> */
public function avatar(): MorphOne { return $this->morphOne(Image::class, 'imageable'); }
public function test() {
$user = new User();
$user->
}
}
";
let (backend, dir) = make_workspace(&[
("src/Models/Image.php", image_php),
("src/Models/User.php", user_php),
]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 9, 15).await;
let props = property_names(&items);
assert!(
props.contains(&"avatar"),
"MorphOne should produce 'avatar' property, got: {:?}",
props
);
}
#[tokio::test]
async fn test_real_property_beats_virtual_relationship_property() {
let profile_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class Profile extends Model {
public function getBio(): string { return ''; }
}
";
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\HasOne;
class User extends Model {
/** A real declared property that shadows the relationship. */
public string $profile = 'default';
/** @return HasOne<\\App\\Models\\Profile, $this> */
public function profile(): HasOne { return $this->hasOne(Profile::class); }
public function test() {
$user = new User();
$user->
}
}
";
let (backend, dir) = make_workspace(&[
("src/Models/Profile.php", profile_php),
("src/Models/User.php", user_php),
]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 11, 15).await;
let profile_props: Vec<&CompletionItem> = items
.iter()
.filter(|i| {
i.kind == Some(CompletionItemKind::PROPERTY)
&& i.filter_text.as_deref().unwrap_or(&i.label) == "profile"
})
.collect();
assert_eq!(
profile_props.len(),
1,
"Should have exactly one 'profile' property (real declared wins), got: {}",
profile_props.len()
);
}
#[tokio::test]
async fn test_cross_file_relationship_property_chain_resolves() {
let profile_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class Profile extends Model {
public function getBio(): string { return ''; }
public function getAvatar(): string { return ''; }
}
";
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\HasOne;
class User extends Model {
/** @return HasOne<\\App\\Models\\Profile, $this> */
public function profile(): HasOne { return $this->hasOne(Profile::class); }
public function test() {
$user = new User();
$user->profile->
}
}
";
let (backend, dir) = make_workspace(&[
("src/Models/Profile.php", profile_php),
("src/Models/User.php", user_php),
]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 9, 24).await;
let methods = method_names(&items);
assert!(
methods.contains(&"getBio"),
"Should chain through relationship property to Profile::getBio cross-file, got: {:?}",
methods
);
assert!(
methods.contains(&"getAvatar"),
"Should chain through relationship property to Profile::getAvatar cross-file, got: {:?}",
methods
);
}
#[tokio::test]
async fn test_relationship_property_chain_after_first() {
let user_info_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class UserInformation extends Model {
public function getAddress(): string { return ''; }
public function getPhone(): string { return ''; }
}
";
let customer_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\HasOne;
class Customer extends Model {
public function getEmail(): string { return ''; }
/** @return HasOne<\\App\\Models\\UserInformation, $this> */
public function userInformation(): HasOne { return $this->hasOne(UserInformation::class); }
public function test() {
$customer = Customer::where('id', 1)->first();
$customer->userInformation->
}
}
";
let (backend, dir) = make_workspace(&[
("src/Models/UserInformation.php", user_info_php),
("src/Models/Customer.php", customer_php),
]);
let items = complete_at(
&backend,
&dir,
"src/Models/Customer.php",
customer_php,
10,
39,
)
.await;
let methods = method_names(&items);
assert!(
methods.contains(&"getAddress"),
"Should chain through userInformation to UserInformation::getAddress, got: {:?}",
methods
);
assert!(
methods.contains(&"getPhone"),
"Should chain through userInformation to UserInformation::getPhone, got: {:?}",
methods
);
assert!(
!methods.contains(&"getEmail"),
"Should NOT offer Customer::getEmail on UserInformation, got: {:?}",
methods
);
}
#[tokio::test]
async fn test_relationship_property_chain_after_first_or_fail() {
let user_info_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class UserInformation extends Model {
public function getAddress(): string { return ''; }
public function getPhone(): string { return ''; }
}
";
let customer_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\HasOne;
class Customer extends Model {
public function getEmail(): string { return ''; }
/** @return HasOne<\\App\\Models\\UserInformation, $this> */
public function userInformation(): HasOne { return $this->hasOne(UserInformation::class); }
public function test() {
$customer = Customer::where('id', 1)->firstOrFail();
$customer->userInformation->
}
}
";
let (backend, dir) = make_workspace(&[
("src/Models/UserInformation.php", user_info_php),
("src/Models/Customer.php", customer_php),
]);
let items = complete_at(
&backend,
&dir,
"src/Models/Customer.php",
customer_php,
10,
39,
)
.await;
let methods = method_names(&items);
assert!(
methods.contains(&"getAddress"),
"Should chain through userInformation to UserInformation::getAddress after firstOrFail(), got: {:?}",
methods
);
assert!(
methods.contains(&"getPhone"),
"Should chain through userInformation to UserInformation::getPhone after firstOrFail(), got: {:?}",
methods
);
assert!(
!methods.contains(&"getEmail"),
"Should NOT offer Customer::getEmail on UserInformation after firstOrFail(), got: {:?}",
methods
);
}
#[tokio::test]
async fn test_skips_methods_without_return_type() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
public function posts() {}
public function test() {
$user = new User();
$user->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 7, 15).await;
let props = property_names(&items);
assert!(
!props.contains(&"posts"),
"Method without return type should not produce a virtual property, got: {:?}",
props
);
}
#[tokio::test]
async fn test_singular_relationship_without_generics_produces_nothing() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\HasOne;
class User extends Model {
public function profile(): HasOne { return $this->hasOne(Profile::class); }
public function test() {
$user = new User();
$user->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 8, 15).await;
let props = property_names(&items);
assert!(
!props.contains(&"profile"),
"Singular relationship without generics should not produce a property (no TRelated), got: {:?}",
props
);
}
#[tokio::test]
async fn test_collection_relationship_without_generics_uses_model_fallback() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\HasMany;
class User extends Model {
public function posts(): HasMany { return $this->hasMany(Post::class); }
public function test() {
$user = new User();
$user->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 8, 15).await;
let props = property_names(&items);
assert!(
props.contains(&"posts"),
"Collection relationship without generics should still produce a property (falls back to Collection<Model>), got: {:?}",
props
);
}
#[tokio::test]
async fn test_same_file_relationship_property_with_plain_backend() {
let backend = create_test_backend();
let uri = Url::parse("file:///laravel_same_file.php").unwrap();
let text = "\
<?php
namespace App\\Models;
class Model extends \\Illuminate\\Database\\Eloquent\\Model {}
class HasMany {}
class Post extends Model {
public function getTitle(): string { return ''; }
}
class User extends Model {
/** @return HasMany<Post, $this> */
public function posts(): HasMany { return new HasMany(); }
public function test() {
$user = new User();
$user->
}
}
";
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 17,
character: 15,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
match result {
Some(CompletionResponse::Array(items))
| Some(CompletionResponse::List(CompletionList { items, .. })) => {
let props = property_names(&items);
let _ = props;
}
_ => {
}
}
}
#[tokio::test]
async fn test_builder_methods_appear_as_static_on_model() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
public function test() {
User::
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 5, 14).await;
let methods = method_names(&items);
assert!(
methods.contains(&"where"),
"Builder's where() should appear as static on User::, got: {:?}",
methods
);
assert!(
methods.contains(&"orderBy"),
"Builder's orderBy() should appear as static on User::, got: {:?}",
methods
);
assert!(
methods.contains(&"get"),
"Builder's get() should appear as static on User::, got: {:?}",
methods
);
assert!(
methods.contains(&"first"),
"Builder's first() should appear as static on User::, got: {:?}",
methods
);
assert!(
methods.contains(&"find"),
"Builder's find() should appear as static on User::, got: {:?}",
methods
);
}
#[tokio::test]
async fn test_builder_where_chain_resolves_to_builder_methods() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
public function test() {
$q = User::where('email', 'foo@bar.com');
$q->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 6, 12).await;
let methods = method_names(&items);
assert!(
methods.contains(&"orderBy"),
"After User::where(), ->orderBy() should be available, got: {:?}",
methods
);
assert!(
methods.contains(&"get"),
"After User::where(), ->get() should be available, got: {:?}",
methods
);
assert!(
methods.contains(&"first"),
"After User::where(), ->first() should be available, got: {:?}",
methods
);
}
#[tokio::test]
async fn test_builder_get_returns_collection_of_model() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
public function getName(): string { return ''; }
public function test() {
$users = User::where('active', true)->get();
$users->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 7, 16).await;
let methods = method_names(&items);
assert!(
methods.contains(&"count"),
"Collection from get() should have count(), got: {:?}",
methods
);
assert!(
methods.contains(&"first"),
"Collection from get() should have first(), got: {:?}",
methods
);
}
#[tokio::test]
async fn test_builder_first_returns_model_instance() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
public function getName(): string { return ''; }
public function test() {
$user = User::where('active', true)->first();
$user->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 7, 15).await;
let methods = method_names(&items);
assert!(
methods.contains(&"getName"),
"first() should return a User instance with getName(), got: {:?}",
methods
);
}
#[tokio::test]
async fn test_builder_first_via_builds_queries_trait() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
public function getName(): string { return ''; }
public function test() {
$user = User::where('active', true)->first();
$user->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 7, 15).await;
let methods = method_names(&items);
assert!(
methods.contains(&"getName"),
"first() via BuildsQueries should return User with getName(), got: {:?}",
methods
);
}
#[tokio::test]
async fn test_builder_mixin_methods_forwarded_to_model() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
public function test() {
User::
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 5, 14).await;
let methods = method_names(&items);
assert!(
methods.contains(&"whereIn"),
"Query\\Builder's whereIn() should appear via @mixin forwarding, got: {:?}",
methods
);
assert!(
methods.contains(&"groupBy"),
"Query\\Builder's groupBy() should appear via @mixin forwarding, got: {:?}",
methods
);
}
#[tokio::test]
async fn test_scope_beats_builder_forwarded_method() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
public function scopeWhere(\\Illuminate\\Database\\Eloquent\\Builder $query, string $col): void {}
public function test() {
User::
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 6, 14).await;
let where_methods: Vec<&CompletionItem> = items
.iter()
.filter(|i| {
i.kind == Some(CompletionItemKind::METHOD)
&& i.filter_text.as_deref().unwrap_or(&i.label) == "where"
})
.collect();
assert!(
!where_methods.is_empty(),
"Should have at least one 'where' method"
);
}
#[tokio::test]
async fn test_builder_forwarding_not_on_non_models() {
let service_php = "\
<?php
namespace App\\Models;
class UserService {
public function test() {
UserService::
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/UserService.php", service_php)]);
let items = complete_at(
&backend,
&dir,
"src/Models/UserService.php",
service_php,
4,
22,
)
.await;
let methods = method_names(&items);
assert!(
!methods.contains(&"where"),
"Non-model class should not have Builder methods, got: {:?}",
methods
);
}
#[tokio::test]
async fn test_builder_non_template_return_types_preserved() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
public function test() {
User::
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 5, 14).await;
let methods = method_names(&items);
assert!(
methods.contains(&"exists"),
"Builder's exists() should be forwarded, got: {:?}",
methods
);
assert!(
methods.contains(&"toSql"),
"Builder's toSql() should be forwarded, got: {:?}",
methods
);
}
#[tokio::test]
async fn test_indirect_model_subclass_gets_builder_forwarding() {
let base_model_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class BaseModel extends Model {}
";
let user_php = "\
<?php
namespace App\\Models;
class User extends BaseModel {
public function getName(): string { return ''; }
public function test() {
User::
}
}
";
let (backend, dir) = make_workspace(&[
("src/Models/BaseModel.php", base_model_php),
("src/Models/User.php", user_php),
]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 5, 15).await;
let methods = method_names(&items);
assert!(
methods.contains(&"where"),
"Indirect model subclass should get Builder forwarding, got: {:?}",
methods
);
}
#[tokio::test]
async fn test_builder_forwarding_coexists_with_relationships_and_scopes() {
let post_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class Post extends Model {
public function getTitle(): string { return ''; }
}
";
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\HasMany;
class User extends Model {
/** @return HasMany<\\App\\Models\\Post, $this> */
public function posts(): HasMany { return $this->hasMany(Post::class); }
public function scopeActive(\\Illuminate\\Database\\Eloquent\\Builder $query): void {}
public function test() {
$user = new User();
$user->
}
}
";
let (backend, dir) = make_workspace(&[
("src/Models/Post.php", post_php),
("src/Models/User.php", user_php),
]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 10, 15).await;
let props = property_names(&items);
let methods = method_names(&items);
assert!(
props.contains(&"posts"),
"Relationship property should appear, got: {:?}",
props
);
assert!(
methods.contains(&"active"),
"Scope method should appear as instance, got: {:?}",
methods
);
assert!(
methods.contains(&"posts"),
"Relationship method should appear, got: {:?}",
methods
);
}
#[tokio::test]
async fn test_provider_priority_laravel_over_phpdoc_over_mixin() {
let post_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class Post extends Model {
public function getTitle(): string { return ''; }
}
";
let mixin_php = "\
<?php
namespace App\\Models;
class PostsMixin {
public string $posts = '';
}
";
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\HasMany;
/**
* @property string $posts
* @mixin PostsMixin
*/
class User extends Model {
/** @return HasMany<\\App\\Models\\Post, $this> */
public function posts(): HasMany { return $this->hasMany(Post::class); }
public function test() {
$user = new User();
$user->
}
}
";
let (backend, dir) = make_workspace(&[
("src/Models/Post.php", post_php),
("src/Models/PostsMixin.php", mixin_php),
("src/Models/User.php", user_php),
]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 13, 15).await;
let posts_props: Vec<&CompletionItem> = items
.iter()
.filter(|i| {
i.kind == Some(CompletionItemKind::PROPERTY)
&& i.filter_text.as_deref().unwrap_or(&i.label) == "posts"
})
.collect();
assert_eq!(
posts_props.len(),
1,
"Should have exactly one 'posts' property despite three sources, got: {}",
posts_props.len()
);
}
#[tokio::test]
async fn test_inline_builder_chain_where_arrow_completion() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
public function test() {
User::where()->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 5, 23).await;
let methods = method_names(&items);
assert!(
methods.contains(&"orderBy"),
"User::where()-> should offer orderBy(), got: {:?}",
methods
);
assert!(
methods.contains(&"get"),
"User::where()-> should offer get(), got: {:?}",
methods
);
assert!(
methods.contains(&"first"),
"User::where()-> should offer first(), got: {:?}",
methods
);
}
#[tokio::test]
async fn test_inline_builder_chain_orderby_arrow_completion() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
public function test() {
User::where()->orderBy('name')->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 5, 40).await;
let methods = method_names(&items);
assert!(
methods.contains(&"get"),
"User::where()->orderBy('name')-> should offer get(), got: {:?}",
methods
);
assert!(
methods.contains(&"first"),
"User::where()->orderBy('name')-> should offer first(), got: {:?}",
methods
);
assert!(
methods.contains(&"limit"),
"User::where()->orderBy('name')-> should offer limit(), got: {:?}",
methods
);
}
#[tokio::test]
async fn test_inline_builder_chain_three_deep() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
public function test() {
User::where()->orderBy('name')->limit(10)->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 5, 55).await;
let methods = method_names(&items);
assert!(
methods.contains(&"get"),
"Three-deep builder chain should offer get(), got: {:?}",
methods
);
assert!(
methods.contains(&"first"),
"Three-deep builder chain should offer first(), got: {:?}",
methods
);
}
#[tokio::test]
async fn test_builder_scope_static_chain_completion() {
let author_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Builder;
class BlogAuthor extends Model {
public function scopeActive(Builder $query): void {}
public function scopeOfGenre(Builder $query, string $genre): void {}
public function test() {
BlogAuthor::
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/BlogAuthor.php", author_php)]);
let items = complete_at(
&backend,
&dir,
"src/Models/BlogAuthor.php",
author_php,
8,
20,
)
.await;
let methods = method_names(&items);
assert!(
methods.contains(&"active"),
"BlogAuthor:: should offer scope method active(), got: {:?}",
methods
);
assert!(
methods.contains(&"ofGenre"),
"BlogAuthor:: should offer scope method ofGenre(), got: {:?}",
methods
);
assert!(
methods.contains(&"where"),
"BlogAuthor:: should offer builder-forwarded where(), got: {:?}",
methods
);
assert!(
methods.contains(&"orderBy"),
"BlogAuthor:: should offer builder-forwarded orderBy(), got: {:?}",
methods
);
}
#[tokio::test]
async fn test_builder_chain_single_file_with_inline_stubs() {
let backend = create_test_backend();
let uri = Url::parse("file:///inline_stubs.php").unwrap();
let text = concat!(
"<?php\n",
"namespace Demo {\n",
"\n",
"class MyUser extends \\Illuminate\\Database\\Eloquent\\Model\n",
"{\n",
" public function getName(): string { return ''; }\n",
" public function demo(): void\n",
" {\n",
" MyUser::where('active', true);\n", " MyUser::where('active', 1)->get();\n", " MyUser::where('active', 1)->first();\n", " MyUser::orderBy('name')->limit(10)->get();\n", " }\n",
"}\n",
"\n",
"} // end namespace Demo\n",
"\n",
"namespace Illuminate\\Database\\Eloquent {\n",
" abstract class Model {\n",
" /** @return \\Illuminate\\Database\\Eloquent\\Builder<static> */\n",
" public static function query() {}\n",
" }\n",
"\n",
" /**\n",
" * @template TModel of \\Illuminate\\Database\\Eloquent\\Model\n",
" * @mixin \\Illuminate\\Database\\Query\\Builder\n",
" */\n",
" class Builder {\n",
" /** @use \\Illuminate\\Database\\Concerns\\BuildsQueries<TModel> */\n",
" use \\Illuminate\\Database\\Concerns\\BuildsQueries;\n",
"\n",
" /** @return $this */\n",
" public function where($column, $operator = null, $value = null) {}\n",
"\n",
" /** @return \\Illuminate\\Database\\Eloquent\\Collection<int, TModel> */\n",
" public function get($columns = ['*']) { return new Collection(); }\n",
" }\n",
"\n",
" /**\n",
" * @template TKey of array-key\n",
" * @template TModel of \\Illuminate\\Database\\Eloquent\\Model\n",
" */\n",
" class Collection {\n",
" /** @return TModel|null */\n",
" public function first(): mixed { return null; }\n",
" public function count(): int { return 0; }\n",
" }\n",
"}\n",
"\n",
"namespace Illuminate\\Database\\Eloquent\\Relations {\n",
" class HasMany {}\n",
" class HasOne {}\n",
" class BelongsTo {}\n",
" class BelongsToMany {}\n",
" class MorphOne {}\n",
" class MorphMany {}\n",
" class MorphTo {}\n",
" class MorphToMany {}\n",
" class HasManyThrough {}\n",
"}\n",
"\n",
"namespace Illuminate\\Database\\Concerns {\n",
" /**\n",
" * @template TValue\n",
" */\n",
" trait BuildsQueries {\n",
" /** @return TValue|null */\n",
" public function first($columns = ['*']) { return null; }\n",
" }\n",
"}\n",
"\n",
"namespace Illuminate\\Database\\Query {\n",
" class Builder {\n",
" /** @return $this */\n",
" public function whereIn($column, $values) { return $this; }\n",
" /** @return $this */\n",
" public function groupBy(...$groups) { return $this; }\n",
" /** @return $this */\n",
" public function orderBy($column, $direction = 'asc') { return $this; }\n",
" /** @return $this */\n",
" public function limit($value) { return $this; }\n",
" }\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let text_with_triggers = concat!(
"<?php\n",
"namespace Demo {\n",
"\n",
"class MyUser extends \\Illuminate\\Database\\Eloquent\\Model\n",
"{\n",
" public function getName(): string { return ''; }\n",
" public function demo(): void\n",
" {\n",
" $q = MyUser::where('active', true);\n", " $q->\n", " }\n",
"}\n",
"\n",
"} // end namespace Demo\n",
"\n",
"namespace Illuminate\\Database\\Eloquent {\n",
" abstract class Model {\n",
" /** @return \\Illuminate\\Database\\Eloquent\\Builder<static> */\n",
" public static function query() {}\n",
" }\n",
"\n",
" /**\n",
" * @template TModel of \\Illuminate\\Database\\Eloquent\\Model\n",
" * @mixin \\Illuminate\\Database\\Query\\Builder\n",
" */\n",
" class Builder {\n",
" /** @use \\Illuminate\\Database\\Concerns\\BuildsQueries<TModel> */\n",
" use \\Illuminate\\Database\\Concerns\\BuildsQueries;\n",
"\n",
" /** @return $this */\n",
" public function where($column, $operator = null, $value = null) {}\n",
"\n",
" /** @return \\Illuminate\\Database\\Eloquent\\Collection<int, TModel> */\n",
" public function get($columns = ['*']) { return new Collection(); }\n",
" }\n",
"\n",
" /**\n",
" * @template TKey of array-key\n",
" * @template TModel of \\Illuminate\\Database\\Eloquent\\Model\n",
" */\n",
" class Collection {\n",
" /** @return TModel|null */\n",
" public function first(): mixed { return null; }\n",
" public function count(): int { return 0; }\n",
" }\n",
"}\n",
"\n",
"namespace Illuminate\\Database\\Eloquent\\Relations {\n",
" class HasMany {}\n",
" class HasOne {}\n",
" class BelongsTo {}\n",
" class BelongsToMany {}\n",
" class MorphOne {}\n",
" class MorphMany {}\n",
" class MorphTo {}\n",
" class MorphToMany {}\n",
" class HasManyThrough {}\n",
"}\n",
"\n",
"namespace Illuminate\\Database\\Concerns {\n",
" /**\n",
" * @template TValue\n",
" */\n",
" trait BuildsQueries {\n",
" /** @return TValue|null */\n",
" public function first($columns = ['*']) { return null; }\n",
" }\n",
"}\n",
"\n",
"namespace Illuminate\\Database\\Query {\n",
" class Builder {\n",
" /** @return $this */\n",
" public function whereIn($column, $values) { return $this; }\n",
" /** @return $this */\n",
" public function groupBy(...$groups) { return $this; }\n",
" /** @return $this */\n",
" public function orderBy($column, $direction = 'asc') { return $this; }\n",
" /** @return $this */\n",
" public function limit($value) { return $this; }\n",
" }\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 2,
text: text_with_triggers.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 9,
character: 12,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(
result.is_some(),
"Should return completion results for $q->"
);
match result.unwrap() {
CompletionResponse::Array(items) => {
let methods: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
eprintln!("Single-file inline stubs: $q-> methods: {:?}", methods);
assert!(
methods.contains(&"get"),
"MyUser::where()-> should offer get() from Eloquent Builder, got: {:?}",
methods
);
assert!(
methods.contains(&"first"),
"MyUser::where()-> should offer first() from BuildsQueries, got: {:?}",
methods
);
assert!(
methods.contains(&"orderBy"),
"MyUser::where()-> should offer orderBy() from Query\\Builder via @mixin, got: {:?}",
methods
);
assert!(
methods.contains(&"limit"),
"MyUser::where()-> should offer limit() from Query\\Builder via @mixin, got: {:?}",
methods
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_inline_chain_after_static_builder_single_file() {
let backend = create_test_backend();
let uri = Url::parse("file:///inline_chain.php").unwrap();
let text = concat!(
"<?php\n",
"namespace Demo {\n",
"\n",
"class BlogAuthor extends \\Illuminate\\Database\\Eloquent\\Model\n",
"{\n",
" public function demo(): void\n",
" {\n",
" BlogAuthor::where('active', 1)->\n", " }\n",
"}\n",
"\n",
"} // end namespace Demo\n",
"\n",
"namespace Illuminate\\Database\\Eloquent {\n",
" abstract class Model {\n",
" /** @return \\Illuminate\\Database\\Eloquent\\Builder<static> */\n",
" public static function query() {}\n",
" }\n",
"\n",
" /**\n",
" * @template TModel of \\Illuminate\\Database\\Eloquent\\Model\n",
" *\n",
" * @mixin \\Illuminate\\Database\\Query\\Builder\n",
" */\n",
" class Builder implements \\Illuminate\\Contracts\\Database\\Eloquent\\Builder {\n",
" /** @use \\Illuminate\\Database\\Concerns\\BuildsQueries<TModel> */\n",
" use \\Illuminate\\Database\\Concerns\\BuildsQueries;\n",
"\n",
" /**\n",
" * @param (\\Closure(static): mixed)|string|array $column\n",
" * @return $this\n",
" */\n",
" public function where($column, $operator = null, $value = null, $boolean = 'and') {}\n",
"\n",
" /** @return \\Illuminate\\Database\\Eloquent\\Collection<int, TModel> */\n",
" public function get($columns = ['*']) { return new Collection(); }\n",
" }\n",
"\n",
" /**\n",
" * @template TKey of array-key\n",
" * @template TModel of \\Illuminate\\Database\\Eloquent\\Model\n",
" */\n",
" class Collection {\n",
" /** @return TModel|null */\n",
" public function first(): mixed { return null; }\n",
" public function count(): int { return 0; }\n",
" }\n",
"}\n",
"\n",
"namespace Illuminate\\Database\\Eloquent\\Relations {\n",
" class HasMany {}\n",
" class HasOne {}\n",
" class BelongsTo {}\n",
" class BelongsToMany {}\n",
" class MorphOne {}\n",
" class MorphMany {}\n",
" class MorphTo {}\n",
" class MorphToMany {}\n",
" class HasManyThrough {}\n",
"}\n",
"\n",
"namespace Illuminate\\Database\\Concerns {\n",
" /**\n",
" * @template TValue\n",
" */\n",
" trait BuildsQueries {\n",
" /** @return TValue|null */\n",
" public function first($columns = ['*']) { return null; }\n",
" }\n",
"}\n",
"\n",
"namespace Illuminate\\Database\\Query {\n",
" class Builder {\n",
" /** @return $this */\n",
" public function whereIn($column, $values) { return $this; }\n",
" /** @return $this */\n",
" public function groupBy(...$groups) { return $this; }\n",
" /** @return $this */\n",
" public function orderBy($column, $direction = 'asc') { return $this; }\n",
" /** @return $this */\n",
" public function limit($value) { return $this; }\n",
" }\n",
"}\n",
"\n",
"namespace Illuminate\\Contracts\\Database\\Eloquent {\n",
" /**\n",
" * @mixin \\Illuminate\\Database\\Eloquent\\Builder\n",
" */\n",
" interface Builder {}\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 7,
character: 42,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(
result.is_some(),
"Should return completion results for BlogAuthor::where(...)->"
);
match result.unwrap() {
CompletionResponse::Array(items) => {
let methods: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
eprintln!(
"Inline chain BlogAuthor::where(...)-> methods: {:?}",
methods
);
assert!(
methods.contains(&"get"),
"Should offer get() from Eloquent Builder, got: {:?}",
methods
);
assert!(
methods.contains(&"first"),
"Should offer first() from BuildsQueries, got: {:?}",
methods
);
assert!(
methods.contains(&"orderBy"),
"Should offer orderBy() from Query\\Builder via @mixin, got: {:?}",
methods
);
assert!(
methods.contains(&"limit"),
"Should offer limit() from Query\\Builder via @mixin, got: {:?}",
methods
);
assert!(
methods.contains(&"where"),
"Should offer where() for continued chaining, got: {:?}",
methods
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_inline_orderby_chain_after_static_builder_single_file() {
let backend = create_test_backend();
let uri = Url::parse("file:///inline_orderby.php").unwrap();
let text = concat!(
"<?php\n",
"namespace Demo {\n",
"\n",
"class BlogAuthor extends \\Illuminate\\Database\\Eloquent\\Model\n",
"{\n",
" public function demo(): void\n",
" {\n",
" BlogAuthor::orderBy('name')->\n",
" }\n",
"}\n",
"\n",
"} // end namespace Demo\n",
"\n",
"namespace Illuminate\\Database\\Eloquent {\n",
" abstract class Model {\n",
" /** @return \\Illuminate\\Database\\Eloquent\\Builder<static> */\n",
" public static function query() {}\n",
" }\n",
"\n",
" /**\n",
" * @template TModel of \\Illuminate\\Database\\Eloquent\\Model\n",
" *\n",
" * @mixin \\Illuminate\\Database\\Query\\Builder\n",
" */\n",
" class Builder implements \\Illuminate\\Contracts\\Database\\Eloquent\\Builder {\n",
" /** @use \\Illuminate\\Database\\Concerns\\BuildsQueries<TModel> */\n",
" use \\Illuminate\\Database\\Concerns\\BuildsQueries;\n",
"\n",
" /**\n",
" * @param (\\Closure(static): mixed)|string|array $column\n",
" * @return $this\n",
" */\n",
" public function where($column, $operator = null, $value = null, $boolean = 'and') {}\n",
"\n",
" /** @return \\Illuminate\\Database\\Eloquent\\Collection<int, TModel> */\n",
" public function get($columns = ['*']) { return new Collection(); }\n",
" }\n",
"\n",
" /**\n",
" * @template TKey of array-key\n",
" * @template TModel of \\Illuminate\\Database\\Eloquent\\Model\n",
" */\n",
" class Collection {\n",
" /** @return TModel|null */\n",
" public function first(): mixed { return null; }\n",
" public function count(): int { return 0; }\n",
" }\n",
"}\n",
"\n",
"namespace Illuminate\\Database\\Eloquent\\Relations {\n",
" class HasMany {}\n",
" class HasOne {}\n",
" class BelongsTo {}\n",
" class BelongsToMany {}\n",
" class MorphOne {}\n",
" class MorphMany {}\n",
" class MorphTo {}\n",
" class MorphToMany {}\n",
" class HasManyThrough {}\n",
"}\n",
"\n",
"namespace Illuminate\\Database\\Concerns {\n",
" /**\n",
" * @template TValue\n",
" */\n",
" trait BuildsQueries {\n",
" /** @return TValue|null */\n",
" public function first($columns = ['*']) { return null; }\n",
" }\n",
"}\n",
"\n",
"namespace Illuminate\\Database\\Query {\n",
" class Builder {\n",
" /** @return $this */\n",
" public function whereIn($column, $values) { return $this; }\n",
" /** @return $this */\n",
" public function groupBy(...$groups) { return $this; }\n",
" /** @return $this */\n",
" public function orderBy($column, $direction = 'asc') { return $this; }\n",
" /** @return $this */\n",
" public function limit($value) { return $this; }\n",
" }\n",
"}\n",
"\n",
"namespace Illuminate\\Contracts\\Database\\Eloquent {\n",
" /**\n",
" * @mixin \\Illuminate\\Database\\Eloquent\\Builder\n",
" */\n",
" interface Builder {}\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 7,
character: 37,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
assert!(
result.is_some(),
"Should return completion results for BlogAuthor::orderBy('name')->"
);
match result.unwrap() {
CompletionResponse::Array(items) => {
let methods: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
eprintln!(
"Inline orderBy chain BlogAuthor::orderBy('name')-> methods: {:?}",
methods
);
assert!(
methods.contains(&"get"),
"Should offer get() after orderBy()->, got: {:?}",
methods
);
assert!(
methods.contains(&"first"),
"Should offer first() after orderBy()->, got: {:?}",
methods
);
assert!(
methods.contains(&"limit"),
"Should offer limit() after orderBy()->, got: {:?}",
methods
);
assert!(
methods.contains(&"where"),
"Should offer where() after orderBy()->, got: {:?}",
methods
);
}
_ => panic!("Expected CompletionResponse::Array"),
}
}
#[tokio::test]
async fn test_collected_by_attribute_builder_get_returns_custom_collection() {
let custom_collection_php = "\
<?php
namespace App\\Collections;
use Illuminate\\Database\\Eloquent\\Collection;
/**
* @template TKey of array-key
* @template TModel
* @extends Collection<TKey, TModel>
*/
class ReviewCollection extends Collection {
/** @return array<TKey, TModel> */
public function topRated(): array { return []; }
}
";
let review_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Attributes\\CollectedBy;
use App\\Collections\\ReviewCollection;
#[CollectedBy(ReviewCollection::class)]
class Review extends Model {
public function getTitle(): string { return ''; }
public function test() {
$reviews = Review::where('active', true)->get();
$reviews->
}
}
";
let (backend, dir) = make_workspace(&[
(
"src/Collections/ReviewCollection.php",
custom_collection_php,
),
("src/Models/Review.php", review_php),
]);
let items = complete_at(&backend, &dir, "src/Models/Review.php", review_php, 10, 18).await;
let methods = method_names(&items);
assert!(
methods.contains(&"topRated"),
"Custom collection from #[CollectedBy] should have topRated(), got: {:?}",
methods
);
assert!(
methods.contains(&"count"),
"Custom collection should inherit count(), got: {:?}",
methods
);
assert!(
methods.contains(&"first"),
"Custom collection should inherit first(), got: {:?}",
methods
);
}
#[tokio::test]
async fn test_collected_by_attribute_first_returns_model() {
let custom_collection_php = "\
<?php
namespace App\\Collections;
use Illuminate\\Database\\Eloquent\\Collection;
/**
* @template TKey of array-key
* @template TModel
* @extends Collection<TKey, TModel>
*/
class ReviewCollection extends Collection {}
";
let review_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Attributes\\CollectedBy;
use App\\Collections\\ReviewCollection;
#[CollectedBy(ReviewCollection::class)]
class Review extends Model {
public function getTitle(): string { return ''; }
public function test() {
$review = Review::where('active', true)->first();
$review->
}
}
";
let (backend, dir) = make_workspace(&[
(
"src/Collections/ReviewCollection.php",
custom_collection_php,
),
("src/Models/Review.php", review_php),
]);
let items = complete_at(&backend, &dir, "src/Models/Review.php", review_php, 10, 17).await;
let methods = method_names(&items);
assert!(
methods.contains(&"getTitle"),
"first() should still return a Review instance with getTitle(), got: {:?}",
methods
);
}
#[tokio::test]
async fn test_has_collection_trait_builder_get_returns_custom_collection() {
let custom_collection_php = "\
<?php
namespace App\\Collections;
use Illuminate\\Database\\Eloquent\\Collection;
/**
* @template TKey of array-key
* @template TModel
* @extends Collection<TKey, TModel>
*/
class InvoiceCollection extends Collection {
/** @return float */
public function totalAmount(): float { return 0.0; }
}
";
let invoice_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\HasCollection;
use App\\Collections\\InvoiceCollection;
class Invoice extends Model {
/** @use HasCollection<InvoiceCollection> */
use HasCollection;
public function getNumber(): string { return ''; }
public function test() {
$invoices = Invoice::where('paid', true)->get();
$invoices->
}
}
";
let (backend, dir) = make_workspace(&[
(
"src/Collections/InvoiceCollection.php",
custom_collection_php,
),
("src/Models/Invoice.php", invoice_php),
]);
let items = complete_at(
&backend,
&dir,
"src/Models/Invoice.php",
invoice_php,
11,
19,
)
.await;
let methods = method_names(&items);
assert!(
methods.contains(&"totalAmount"),
"Custom collection from @use HasCollection<> should have totalAmount(), got: {:?}",
methods
);
assert!(
methods.contains(&"count"),
"Custom collection should inherit count(), got: {:?}",
methods
);
}
#[tokio::test]
async fn test_has_collection_trait_first_returns_model() {
let custom_collection_php = "\
<?php
namespace App\\Collections;
use Illuminate\\Database\\Eloquent\\Collection;
/**
* @template TKey of array-key
* @template TModel
* @extends Collection<TKey, TModel>
*/
class InvoiceCollection extends Collection {}
";
let invoice_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\HasCollection;
use App\\Collections\\InvoiceCollection;
class Invoice extends Model {
/** @use HasCollection<InvoiceCollection> */
use HasCollection;
public function getNumber(): string { return ''; }
public function test() {
$inv = Invoice::where('paid', true)->first();
$inv->
}
}
";
let (backend, dir) = make_workspace(&[
(
"src/Collections/InvoiceCollection.php",
custom_collection_php,
),
("src/Models/Invoice.php", invoice_php),
]);
let items = complete_at(
&backend,
&dir,
"src/Models/Invoice.php",
invoice_php,
11,
14,
)
.await;
let methods = method_names(&items);
assert!(
methods.contains(&"getNumber"),
"first() should return an Invoice with getNumber(), got: {:?}",
methods
);
}
#[tokio::test]
async fn test_collected_by_relationship_property_uses_custom_collection() {
let review_collection_php = "\
<?php
namespace App\\Collections;
use Illuminate\\Database\\Eloquent\\Collection;
/**
* @template TKey of array-key
* @template TModel
* @extends Collection<TKey, TModel>
*/
class ReviewCollection extends Collection {
/** @return array */
public function topRated(): array { return []; }
}
";
let product_collection_php = "\
<?php
namespace App\\Collections;
use Illuminate\\Database\\Eloquent\\Collection;
/**
* @template TKey of array-key
* @template TModel
* @extends Collection<TKey, TModel>
*/
class ProductCollection extends Collection {
/** @return array */
public function bestSellers(): array { return []; }
}
";
let review_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Attributes\\CollectedBy;
use App\\Collections\\ReviewCollection;
#[CollectedBy(ReviewCollection::class)]
class Review extends Model {
public function getTitle(): string { return ''; }
/** @return \\Illuminate\\Database\\Eloquent\\Relations\\HasMany<Review, $this> */
public function childReviews(): mixed { return $this->hasMany(Review::class); }
}
";
let product_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Attributes\\CollectedBy;
use App\\Collections\\ProductCollection;
#[CollectedBy(ProductCollection::class)]
class Product extends Model {
/** @return \\Illuminate\\Database\\Eloquent\\Relations\\HasMany<Review, $this> */
public function reviews(): mixed { return $this->hasMany(Review::class); }
public function test() {
$product = new Product();
$product->reviews->
}
}
";
let (backend, dir) = make_workspace(&[
(
"src/Collections/ReviewCollection.php",
review_collection_php,
),
(
"src/Collections/ProductCollection.php",
product_collection_php,
),
("src/Models/Review.php", review_php),
("src/Models/Product.php", product_php),
]);
let items = complete_at(
&backend,
&dir,
"src/Models/Product.php",
product_php,
11,
28,
)
.await;
let methods = method_names(&items);
assert!(
methods.contains(&"topRated"),
"HasMany relationship property should use the related model's ReviewCollection (topRated()), got: {:?}",
methods
);
assert!(
!methods.contains(&"bestSellers"),
"HasMany relationship property should NOT use the owning model's ProductCollection (bestSellers()), got: {:?}",
methods
);
}
#[tokio::test]
async fn test_model_without_custom_collection_uses_standard_collection() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
public function getName(): string { return ''; }
public function test() {
$users = User::where('active', true)->get();
$users->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 7, 16).await;
let methods = method_names(&items);
assert!(
methods.contains(&"count"),
"Standard Collection should have count(), got: {:?}",
methods
);
assert!(
methods.contains(&"first"),
"Standard Collection should have first(), got: {:?}",
methods
);
}
#[tokio::test]
async fn test_collected_by_fqn_argument() {
let custom_collection_php = "\
<?php
namespace App\\Collections;
use Illuminate\\Database\\Eloquent\\Collection;
/**
* @template TKey of array-key
* @template TModel
* @extends Collection<TKey, TModel>
*/
class OrderCollection extends Collection {
/** @return float */
public function grandTotal(): float { return 0.0; }
}
";
let order_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Attributes\\CollectedBy;
#[CollectedBy(\\App\\Collections\\OrderCollection::class)]
class Order extends Model {
public function test() {
$orders = Order::where('status', 'paid')->get();
$orders->
}
}
";
let (backend, dir) = make_workspace(&[
("src/Collections/OrderCollection.php", custom_collection_php),
("src/Models/Order.php", order_php),
]);
let items = complete_at(&backend, &dir, "src/Models/Order.php", order_php, 8, 17).await;
let methods = method_names(&items);
assert!(
methods.contains(&"grandTotal"),
"FQN #[CollectedBy] should resolve custom collection with grandTotal(), got: {:?}",
methods
);
}
#[tokio::test]
async fn test_custom_collection_same_file_plain_backend() {
let backend = create_test_backend();
let uri = Url::parse("file:///custom_coll.php").unwrap();
let text = concat!(
"<?php\n",
"namespace Illuminate\\Database\\Eloquent {\n",
" abstract class Model {\n",
" /** @return \\Illuminate\\Database\\Eloquent\\Builder<static> */\n",
" public static function query() {}\n",
" }\n",
" /**\n",
" * @template TModel of \\Illuminate\\Database\\Eloquent\\Model\n",
" * @mixin \\Illuminate\\Database\\Query\\Builder\n",
" */\n",
" class Builder {\n",
" /** @return $this */\n",
" public function where($column, $value = null) {}\n",
" /** @return \\Illuminate\\Database\\Eloquent\\Collection<int, TModel> */\n",
" public function get() {}\n",
" }\n",
" /**\n",
" * @template TKey of array-key\n",
" * @template TModel\n",
" */\n",
" class Collection {\n",
" /** @return TModel|null */\n",
" public function first(): mixed {}\n",
" public function count(): int {}\n",
" }\n",
"}\n",
"namespace Illuminate\\Database\\Eloquent\\Attributes {\n",
" class CollectedBy { public function __construct(string $collectionClass) {} }\n",
"}\n",
"namespace Illuminate\\Database\\Eloquent\\Relations {\n",
" class HasMany {}\n",
" class HasOne {}\n",
"}\n",
"namespace Illuminate\\Database\\Query {\n",
" class Builder {}\n",
"}\n",
"namespace Illuminate\\Database\\Eloquent {\n",
" /** @template TCollection */\n",
" trait HasCollection {}\n",
"}\n",
"namespace App\\Collections {\n",
" /**\n",
" * @template TKey of array-key\n",
" * @template TModel\n",
" * @extends \\Illuminate\\Database\\Eloquent\\Collection<TKey, TModel>\n",
" */\n",
" class TaskCollection extends \\Illuminate\\Database\\Eloquent\\Collection {\n",
" /** @return array */\n",
" public function overdue(): array { return []; }\n",
" }\n",
"}\n",
"namespace App\\Models {\n",
" use Illuminate\\Database\\Eloquent\\Model;\n",
" use Illuminate\\Database\\Eloquent\\HasCollection;\n",
" use App\\Collections\\TaskCollection;\n",
" class Task extends Model {\n",
" /** @use HasCollection<TaskCollection> */\n",
" use HasCollection;\n",
" public function getTitle(): string { return ''; }\n",
" public function demo() {\n",
" $tasks = Task::where('done', false)->get();\n",
" $tasks->\n",
" }\n",
" }\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 61,
character: 20,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
let items = match result {
Some(CompletionResponse::Array(items)) => items,
Some(CompletionResponse::List(list)) => list.items,
_ => Vec::new(),
};
let methods = method_names(&items);
assert!(
methods.contains(&"overdue"),
"Same-file custom collection should have overdue(), got: {:?}",
methods
);
assert!(
methods.contains(&"count"),
"Same-file custom collection should inherit count(), got: {:?}",
methods
);
}
#[tokio::test]
async fn test_accessor_on_variable_same_file_inline_stubs() {
let backend = create_test_backend();
let uri = Url::parse("file:///accessor_var.php").unwrap();
let text = concat!(
"<?php\n",
"namespace Demo {\n",
"\n",
"class AccessorDemo extends \\Illuminate\\Database\\Eloquent\\Model\n",
"{\n",
" public function getDisplayNameAttribute(): string\n",
" {\n",
" return 'display';\n",
" }\n",
"\n",
" protected function avatarUrl(): \\Illuminate\\Database\\Eloquent\\Casts\\Attribute\n",
" {\n",
" return new \\Illuminate\\Database\\Eloquent\\Casts\\Attribute();\n",
" }\n",
"\n",
" public function demo(): void\n",
" {\n",
" $model = new AccessorDemo();\n",
" $model->\n",
" }\n",
"}\n",
"\n",
"} // end namespace Demo\n",
"\n",
"namespace Illuminate\\Database\\Eloquent {\n",
" abstract class Model {}\n",
"}\n",
"\n",
"namespace Illuminate\\Database\\Eloquent\\Casts {\n",
" class Attribute {}\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 18,
character: 16,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
let items = match result {
Some(CompletionResponse::Array(items)) => items,
Some(CompletionResponse::List(list)) => list.items,
_ => Vec::new(),
};
let props = property_names(&items);
assert!(
props.contains(&"display_name"),
"Legacy accessor getDisplayNameAttribute should produce property display_name on $model->, got: {:?}",
props
);
assert!(
props.contains(&"avatar_url"),
"Modern accessor avatarUrl() should produce property avatar_url on $model->, got: {:?}",
props
);
}
#[tokio::test]
async fn test_legacy_accessor_produces_virtual_property() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
public function getFullNameAttribute(): string { return ''; }
public function test() {
$u = new User();
$u->full_name->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 7, 12).await;
let props = property_names(&items);
assert!(
props.contains(&"full_name"),
"Legacy accessor getFullNameAttribute should produce property full_name, got: {:?}",
props
);
}
#[tokio::test]
async fn test_legacy_accessor_multiple() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
public function getFirstNameAttribute(): string { return ''; }
public function getLastNameAttribute(): string { return ''; }
public function getIsAdminAttribute(): bool { return false; }
public function greet(): string { return ''; }
public function test() {
$u = new User();
$u->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 10, 12).await;
let props = property_names(&items);
let methods = method_names(&items);
assert!(
props.contains(&"first_name"),
"Should have first_name property, got: {:?}",
props
);
assert!(
props.contains(&"last_name"),
"Should have last_name property, got: {:?}",
props
);
assert!(
props.contains(&"is_admin"),
"Should have is_admin property, got: {:?}",
props
);
assert!(
methods.contains(&"greet"),
"Regular method greet() should still appear, got: {:?}",
methods
);
}
#[tokio::test]
async fn test_get_attribute_not_treated_as_accessor() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
public function getAttribute(string $key): mixed { return null; }
public function test() {
$u = new User();
$u->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 7, 12).await;
let props = property_names(&items);
assert!(
!props.iter().any(|p| p.is_empty()),
"getAttribute should not produce an empty-named property, got: {:?}",
props
);
}
#[tokio::test]
async fn test_modern_accessor_produces_virtual_property() {
let attribute_php = "\
<?php
namespace Illuminate\\Database\\Eloquent\\Casts;
class Attribute {}
";
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Casts\\Attribute;
class User extends Model {
protected function fullName(): Attribute {
return Attribute::make(get: fn() => 'hello');
}
public function test() {
$u = new User();
$u->
}
}
";
let (backend, dir) = make_workspace(&[
(
"vendor/illuminate/Eloquent/Casts/Attribute.php",
attribute_php,
),
("src/Models/User.php", user_php),
]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 10, 12).await;
let props = property_names(&items);
assert!(
props.contains(&"full_name"),
"Modern accessor fullName() returning Attribute should produce property full_name, got: {:?}",
props
);
}
#[tokio::test]
async fn test_modern_accessor_generic_type_extracted() {
let attribute_php = "\
<?php
namespace Illuminate\\Database\\Eloquent\\Casts;
class Attribute {}
";
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Casts\\Attribute;
class User extends Model {
/** @return Attribute<string> */
protected function fullName(): Attribute {
return Attribute::make(get: fn() => 'hello');
}
/** @return Attribute<int, never> */
protected function age(): Attribute {
return Attribute::make(get: fn() => 42);
}
/** @return Attribute<string|null> */
protected function nickname(): Attribute {
return Attribute::make(get: fn() => null);
}
protected function noGeneric(): Attribute {
return Attribute::make(get: fn() => 'fallback');
}
public function test() {
$u = new User();
$u->
}
}
";
let (backend, dir) = make_workspace(&[
(
"vendor/illuminate/Eloquent/Casts/Attribute.php",
attribute_php,
),
("src/Models/User.php", user_php),
]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 22, 12).await;
let find_prop = |name: &str| -> Option<&CompletionItem> {
items.iter().find(|i| {
i.kind == Some(CompletionItemKind::PROPERTY)
&& i.filter_text.as_deref().unwrap_or(&i.label) == name
})
};
let full_name = find_prop("full_name");
assert!(full_name.is_some(), "full_name property should exist");
assert!(
full_name
.unwrap()
.detail
.as_deref()
.unwrap_or("")
.contains("string"),
"full_name should be typed string, got: {:?}",
full_name.unwrap().detail
);
assert!(
!full_name
.unwrap()
.detail
.as_deref()
.unwrap_or("")
.contains("mixed"),
"full_name should NOT be typed mixed, got: {:?}",
full_name.unwrap().detail
);
let age = find_prop("age");
assert!(age.is_some(), "age property should exist");
assert!(
age.unwrap().detail.as_deref().unwrap_or("").contains("int"),
"age should be typed int, got: {:?}",
age.unwrap().detail
);
let nickname = find_prop("nickname");
assert!(nickname.is_some(), "nickname property should exist");
assert!(
nickname
.unwrap()
.detail
.as_deref()
.unwrap_or("")
.contains("string|null"),
"nickname should be typed string|null, got: {:?}",
nickname.unwrap().detail
);
let no_generic = find_prop("no_generic");
assert!(no_generic.is_some(), "no_generic property should exist");
assert!(
no_generic
.unwrap()
.detail
.as_deref()
.unwrap_or("")
.contains("mixed"),
"no_generic should fall back to mixed, got: {:?}",
no_generic.unwrap().detail
);
}
#[tokio::test]
async fn test_accessors_coexist_with_scopes_and_relationships() {
let attribute_php = "\
<?php
namespace Illuminate\\Database\\Eloquent\\Casts;
class Attribute {}
";
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Builder;
use Illuminate\\Database\\Eloquent\\Casts\\Attribute;
class User extends Model {
public function getDisplayNameAttribute(): string { return ''; }
protected function isVerified(): Attribute {
return Attribute::make(get: fn() => true);
}
public function scopeActive(Builder $query): void {}
/** @return \\Illuminate\\Database\\Eloquent\\Relations\\HasMany<User, $this> */
public function friends(): mixed { return $this->hasMany(User::class); }
public function test() {
$u = new User();
$u->
}
}
";
let (backend, dir) = make_workspace(&[
(
"vendor/illuminate/Eloquent/Casts/Attribute.php",
attribute_php,
),
("src/Models/User.php", user_php),
]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 15, 12).await;
let props = property_names(&items);
let methods = method_names(&items);
assert!(
props.contains(&"display_name"),
"Legacy accessor property display_name should be present, got: {:?}",
props
);
assert!(
props.contains(&"is_verified"),
"Modern accessor property is_verified should be present, got: {:?}",
props
);
assert!(
props.contains(&"friends"),
"Relationship property friends should be present, got: {:?}",
props
);
assert!(
methods.contains(&"active"),
"Scope method active() should be present, got: {:?}",
methods
);
}
#[tokio::test]
async fn test_modern_accessor_cross_file() {
let attribute_php = "\
<?php
namespace Illuminate\\Database\\Eloquent\\Casts;
class Attribute {}
";
let profile_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Casts\\Attribute;
class Profile extends Model {
protected function avatarUrl(): Attribute {
return Attribute::make(get: fn() => 'https://example.com/avatar.png');
}
}
";
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
/** @return \\Illuminate\\Database\\Eloquent\\Relations\\HasOne<Profile, $this> */
public function profile(): mixed { return $this->hasOne(Profile::class); }
public function test() {
$u = new User();
$u->profile->
}
}
";
let (backend, dir) = make_workspace(&[
(
"vendor/illuminate/Eloquent/Casts/Attribute.php",
attribute_php,
),
("src/Models/Profile.php", profile_php),
("src/Models/User.php", user_php),
]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 8, 22).await;
let props = property_names(&items);
assert!(
props.contains(&"avatar_url"),
"Cross-file modern accessor avatarUrl() on Profile should produce property avatar_url, got: {:?}",
props
);
}
#[tokio::test]
async fn test_find_or_fail_conditional_return_resolves_to_model() {
let customer_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class Customer extends Model {
public function getEmail(): string { return ''; }
public function getFullNameAttribute(): string { return ''; }
}
";
let controller_php = "\
<?php
namespace App\\Models;
class OrderController {
public function show(): void {
$customer = Customer::findOrFail(1);
$customer->
}
}
";
let (backend, dir) = make_workspace(&[
("src/Models/Customer.php", customer_php),
("src/Models/OrderController.php", controller_php),
]);
let items = complete_at(
&backend,
&dir,
"src/Models/OrderController.php",
controller_php,
5,
20,
)
.await;
let methods = method_names(&items);
let props = property_names(&items);
assert!(
methods.contains(&"getEmail"),
"Customer::findOrFail(1) should resolve to Customer with getEmail(), got methods: {:?}, props: {:?}",
methods,
props
);
assert!(
props.contains(&"full_name"),
"Customer::findOrFail(1) should resolve accessor property full_name, got props: {:?}",
props
);
}
#[tokio::test]
async fn test_find_or_fail_array_arg_resolves_to_collection() {
let customer_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class Customer extends Model {
public function getEmail(): string { return ''; }
}
";
let controller_php = "\
<?php
namespace App\\Models;
class OrderController {
public function index(): void {
$customers = Customer::findOrFail([1, 2]);
$customers->
}
}
";
let (backend, dir) = make_workspace(&[
("src/Models/Customer.php", customer_php),
("src/Models/OrderController.php", controller_php),
]);
let items = complete_at(
&backend,
&dir,
"src/Models/OrderController.php",
controller_php,
5,
21,
)
.await;
let methods = method_names(&items);
assert!(
methods.contains(&"count"),
"Customer::findOrFail([1, 2]) should resolve to Collection with count(), got methods: {:?}",
methods
);
assert!(
methods.contains(&"first"),
"Customer::findOrFail([1, 2]) should resolve to Collection with first(), got methods: {:?}",
methods
);
}
#[tokio::test]
async fn test_body_inferred_has_many_produces_property() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
public function posts() { return $this->hasMany(Post::class); }
public function test() {
$user = new User();
$user->
}
}
";
let post_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class Post extends Model {
public function getTitle(): string { return ''; }
}
";
let (backend, dir) = make_workspace(&[
("src/Models/User.php", user_php),
("src/Models/Post.php", post_php),
]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 7, 15).await;
let props = property_names(&items);
assert!(
props.contains(&"posts"),
"Body-inferred hasMany should produce a 'posts' property, got: {:?}",
props
);
}
#[tokio::test]
async fn test_body_inferred_has_one_produces_property() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
public function profile() { return $this->hasOne(Profile::class); }
public function test() {
$user = new User();
$user->
}
}
";
let profile_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class Profile extends Model {
public function getBio(): string { return ''; }
}
";
let (backend, dir) = make_workspace(&[
("src/Models/User.php", user_php),
("src/Models/Profile.php", profile_php),
]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 7, 15).await;
let props = property_names(&items);
assert!(
props.contains(&"profile"),
"Body-inferred hasOne should produce a 'profile' property, got: {:?}",
props
);
}
#[tokio::test]
async fn test_body_inferred_belongs_to_produces_property() {
let post_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class Post extends Model {
public function author() { return $this->belongsTo(User::class); }
public function test() {
$post = new Post();
$post->
}
}
";
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
public function getName(): string { return ''; }
}
";
let (backend, dir) = make_workspace(&[
("src/Models/Post.php", post_php),
("src/Models/User.php", user_php),
]);
let items = complete_at(&backend, &dir, "src/Models/Post.php", post_php, 7, 15).await;
let props = property_names(&items);
assert!(
props.contains(&"author"),
"Body-inferred belongsTo should produce an 'author' property, got: {:?}",
props
);
}
#[tokio::test]
async fn test_body_inferred_morph_to_produces_property() {
let comment_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class Comment extends Model {
public function commentable() { return $this->morphTo(); }
public function test() {
$c = new Comment();
$c->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/Comment.php", comment_php)]);
let items = complete_at(&backend, &dir, "src/Models/Comment.php", comment_php, 7, 13).await;
let props = property_names(&items);
assert!(
props.contains(&"commentable"),
"Body-inferred morphTo should produce a 'commentable' property, got: {:?}",
props
);
}
#[tokio::test]
async fn test_body_inferred_relationship_chain_resolves() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
public function profile() { return $this->hasOne(Profile::class); }
public function test() {
$user = new User();
$user->profile->
}
}
";
let profile_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class Profile extends Model {
public function getBio(): string { return ''; }
}
";
let (backend, dir) = make_workspace(&[
("src/Models/User.php", user_php),
("src/Models/Profile.php", profile_php),
]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 7, 24).await;
let methods = method_names(&items);
assert!(
methods.contains(&"getBio"),
"Chaining through body-inferred hasOne property should resolve to related class, got: {:?}",
methods
);
}
#[tokio::test]
async fn test_body_inferred_fqn_class_argument() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
public function posts() { return $this->hasMany(\\App\\Models\\Post::class); }
public function test() {
$user = new User();
$user->
}
}
";
let post_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class Post extends Model {
public function getTitle(): string { return ''; }
}
";
let (backend, dir) = make_workspace(&[
("src/Models/User.php", user_php),
("src/Models/Post.php", post_php),
]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 7, 15).await;
let props = property_names(&items);
assert!(
props.contains(&"posts"),
"Body-inferred hasMany with FQN class argument should produce a 'posts' property, got: {:?}",
props
);
}
#[tokio::test]
async fn test_body_inferred_empty_body_still_skipped() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
public function posts() {}
public function test() {
$user = new User();
$user->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 7, 15).await;
let props = property_names(&items);
assert!(
!props.contains(&"posts"),
"Empty method body should not produce a virtual property, got: {:?}",
props
);
}
#[tokio::test]
async fn test_body_inferred_with_extra_arguments() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
public function posts() { return $this->hasMany(Post::class, 'author_id', 'id'); }
public function test() {
$user = new User();
$user->
}
}
";
let post_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class Post extends Model {}
";
let (backend, dir) = make_workspace(&[
("src/Models/User.php", user_php),
("src/Models/Post.php", post_php),
]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 7, 15).await;
let props = property_names(&items);
assert!(
props.contains(&"posts"),
"Body-inferred hasMany with extra FK arguments should produce a 'posts' property, got: {:?}",
props
);
}
#[tokio::test]
async fn test_body_inferred_does_not_override_docblock() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
/** @return \\Illuminate\\Database\\Eloquent\\Relations\\HasMany<Post, $this> */
public function posts() { return $this->hasMany(Post::class); }
public function test() {
$user = new User();
$user->
}
}
";
let post_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class Post extends Model {
public function getTitle(): string { return ''; }
}
";
let (backend, dir) = make_workspace(&[
("src/Models/User.php", user_php),
("src/Models/Post.php", post_php),
]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 8, 15).await;
let props = property_names(&items);
assert!(
props.contains(&"posts"),
"Docblock @return should still produce a 'posts' property (body inference not needed), got: {:?}",
props
);
}
#[tokio::test]
async fn test_body_inferred_relationship_with_chained_builder() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
public function posts() { return $this->hasMany(Post::class)->latest(); }
public function test() {
$user = new User();
$user->
}
}
";
let post_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class Post extends Model {}
";
let (backend, dir) = make_workspace(&[
("src/Models/User.php", user_php),
("src/Models/Post.php", post_php),
]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 7, 15).await;
let props = property_names(&items);
assert!(
props.contains(&"posts"),
"Body-inferred hasMany with chained ->latest() should produce a 'posts' property, got: {:?}",
props
);
}
#[tokio::test]
async fn test_body_inferred_morph_many_produces_property() {
let post_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class Post extends Model {
public function comments() { return $this->morphMany(Comment::class, 'commentable'); }
public function test() {
$post = new Post();
$post->
}
}
";
let comment_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class Comment extends Model {}
";
let (backend, dir) = make_workspace(&[
("src/Models/Post.php", post_php),
("src/Models/Comment.php", comment_php),
]);
let items = complete_at(&backend, &dir, "src/Models/Post.php", post_php, 7, 15).await;
let props = property_names(&items);
assert!(
props.contains(&"comments"),
"Body-inferred morphMany should produce a 'comments' property, got: {:?}",
props
);
}
#[tokio::test]
async fn test_body_inferred_belongs_to_many_produces_property() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
public function roles() { return $this->belongsToMany(Role::class); }
public function test() {
$user = new User();
$user->
}
}
";
let role_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class Role extends Model {}
";
let (backend, dir) = make_workspace(&[
("src/Models/User.php", user_php),
("src/Models/Role.php", role_php),
]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 7, 15).await;
let props = property_names(&items);
assert!(
props.contains(&"roles"),
"Body-inferred belongsToMany should produce a 'roles' property, got: {:?}",
props
);
}
#[tokio::test]
async fn test_body_inferred_this_arrow_shows_relationship_properties() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
public function posts() { return $this->hasMany(Post::class); }
public function test() {
$this->
}
}
";
let post_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class Post extends Model {}
";
let (backend, dir) = make_workspace(&[
("src/Models/User.php", user_php),
("src/Models/Post.php", post_php),
]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 6, 16).await;
let props = property_names(&items);
assert!(
props.contains(&"posts"),
"$this-> should show body-inferred relationship properties, got: {:?}",
props
);
}
#[tokio::test]
async fn test_body_inferred_non_model_class_no_property() {
let service_php = "\
<?php
namespace App\\Services;
class UserService {
public function posts() { return $this->hasMany(Post::class); }
public function test() {
$s = new UserService();
$s->
}
}
";
let (backend, dir) = create_psr4_workspace(
r#"{ "autoload": { "psr-4": { "App\\Services\\": "src/Services/" } } }"#,
&[("src/Services/UserService.php", service_php)],
);
let items = complete_at(
&backend,
&dir,
"src/Services/UserService.php",
service_php,
5,
13,
)
.await;
let props = property_names(&items);
assert!(
!props.contains(&"posts"),
"Non-model classes should not get virtual relationship properties even if body matches, got: {:?}",
props
);
}
#[tokio::test]
async fn test_new_collection_override_builder_get_returns_custom_collection() {
let custom_collection_php = "\
<?php
namespace App\\Collections;
use Illuminate\\Database\\Eloquent\\Collection;
/**
* @template TKey of array-key
* @template TModel
* @extends Collection<TKey, TModel>
*/
class TaskCollection extends Collection {
/** @return array<TKey, TModel> */
public function pending(): array { return []; }
}
";
let task_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use App\\Collections\\TaskCollection;
class Task extends Model {
/** @return TaskCollection<int, static> */
public function newCollection(array $models = []): TaskCollection
{
return new TaskCollection($models);
}
public function getTitle(): string { return ''; }
public function test() {
$tasks = Task::where('active', true)->get();
$tasks->
}
}
";
let (backend, dir) = make_workspace(&[
("src/Collections/TaskCollection.php", custom_collection_php),
("src/Models/Task.php", task_php),
]);
let items = complete_at(&backend, &dir, "src/Models/Task.php", task_php, 13, 16).await;
let methods = method_names(&items);
assert!(
methods.contains(&"pending"),
"Custom collection from newCollection() should have pending(), got: {:?}",
methods
);
assert!(
methods.contains(&"count"),
"Custom collection should inherit count(), got: {:?}",
methods
);
}
#[tokio::test]
async fn test_new_collection_override_first_returns_model() {
let custom_collection_php = "\
<?php
namespace App\\Collections;
use Illuminate\\Database\\Eloquent\\Collection;
/**
* @template TKey of array-key
* @template TModel
* @extends Collection<TKey, TModel>
*/
class TaskCollection extends Collection {}
";
let task_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use App\\Collections\\TaskCollection;
class Task extends Model {
/** @return TaskCollection<int, static> */
public function newCollection(array $models = []): TaskCollection
{
return new TaskCollection($models);
}
public function getTitle(): string { return ''; }
public function test() {
$task = Task::where('active', true)->first();
$task->
}
}
";
let (backend, dir) = make_workspace(&[
("src/Collections/TaskCollection.php", custom_collection_php),
("src/Models/Task.php", task_php),
]);
let items = complete_at(&backend, &dir, "src/Models/Task.php", task_php, 13, 15).await;
let methods = method_names(&items);
assert!(
methods.contains(&"getTitle"),
"first() on model with newCollection() should still return model, got: {:?}",
methods
);
}
#[tokio::test]
async fn test_new_collection_override_relationship_property_uses_custom_collection() {
let custom_collection_php = "\
<?php
namespace App\\Collections;
use Illuminate\\Database\\Eloquent\\Collection;
/**
* @template TKey of array-key
* @template TModel
* @extends Collection<TKey, TModel>
*/
class TaskCollection extends Collection {
/** @return array<TKey, TModel> */
public function pending(): array { return []; }
}
";
let task_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use App\\Collections\\TaskCollection;
class Task extends Model {
/** @return TaskCollection<int, static> */
public function newCollection(array $models = []): TaskCollection
{
return new TaskCollection($models);
}
public function getTitle(): string { return ''; }
}
";
let project_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class Project extends Model {
/** @return \\Illuminate\\Database\\Eloquent\\Relations\\HasMany<Task, $this> */
public function tasks(): mixed { return $this->hasMany(Task::class); }
public function test() {
$project = new Project();
$project->tasks->
}
}
";
let (backend, dir) = make_workspace(&[
("src/Collections/TaskCollection.php", custom_collection_php),
("src/Models/Task.php", task_php),
("src/Models/Project.php", project_php),
]);
let items = complete_at(&backend, &dir, "src/Models/Project.php", project_php, 8, 25).await;
let methods = method_names(&items);
assert!(
methods.contains(&"pending"),
"Relationship property should use related model's newCollection() custom collection, got: {:?}",
methods
);
}
#[tokio::test]
async fn test_collected_by_takes_priority_over_new_collection() {
let collection_a_php = "\
<?php
namespace App\\Collections;
use Illuminate\\Database\\Eloquent\\Collection;
/**
* @template TKey of array-key
* @template TModel
* @extends Collection<TKey, TModel>
*/
class CollectionA extends Collection {
public function fromAttribute(): string { return ''; }
}
";
let collection_b_php = "\
<?php
namespace App\\Collections;
use Illuminate\\Database\\Eloquent\\Collection;
/**
* @template TKey of array-key
* @template TModel
* @extends Collection<TKey, TModel>
*/
class CollectionB extends Collection {
public function fromMethod(): string { return ''; }
}
";
let widget_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Attributes\\CollectedBy;
use App\\Collections\\CollectionA;
use App\\Collections\\CollectionB;
#[CollectedBy(CollectionA::class)]
class Widget extends Model {
/** @return CollectionB<int, static> */
public function newCollection(array $models = []): CollectionB
{
return new CollectionB($models);
}
public function test() {
$widgets = Widget::where('active', true)->get();
$widgets->
}
}
";
let (backend, dir) = make_workspace(&[
("src/Collections/CollectionA.php", collection_a_php),
("src/Collections/CollectionB.php", collection_b_php),
("src/Models/Widget.php", widget_php),
]);
let items = complete_at(&backend, &dir, "src/Models/Widget.php", widget_php, 15, 18).await;
let methods = method_names(&items);
assert!(
methods.contains(&"fromAttribute"),
"#[CollectedBy] should take priority over newCollection(), got: {:?}",
methods
);
assert!(
!methods.contains(&"fromMethod"),
"newCollection() should NOT be used when #[CollectedBy] is present, got: {:?}",
methods
);
}
#[tokio::test]
async fn test_new_collection_standard_return_type_ignored() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Collection;
class User extends Model {
public function newCollection(array $models = []): Collection
{
return new Collection($models);
}
public function getName(): string { return ''; }
public function test() {
$users = User::where('active', true)->get();
$users->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 12, 16).await;
let methods = method_names(&items);
assert!(
methods.contains(&"count"),
"Standard collection should still have count(), got: {:?}",
methods
);
assert!(
!methods.iter().any(|m| m == &"pending"),
"No custom methods should appear when newCollection returns standard Collection"
);
}
#[tokio::test]
async fn test_new_collection_fqn_return_type() {
let custom_collection_php = "\
<?php
namespace App\\Collections;
use Illuminate\\Database\\Eloquent\\Collection;
/**
* @template TKey of array-key
* @template TModel
* @extends Collection<TKey, TModel>
*/
class EventCollection extends Collection {
public function upcoming(): array { return []; }
}
";
let event_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class Event extends Model {
public function newCollection(array $models = []): \\App\\Collections\\EventCollection
{
return new \\App\\Collections\\EventCollection($models);
}
public function test() {
$events = Event::where('upcoming', true)->get();
$events->
}
}
";
let (backend, dir) = make_workspace(&[
("src/Collections/EventCollection.php", custom_collection_php),
("src/Models/Event.php", event_php),
]);
let items = complete_at(&backend, &dir, "src/Models/Event.php", event_php, 10, 17).await;
let methods = method_names(&items);
assert!(
methods.contains(&"upcoming"),
"FQN return type on newCollection() should be detected, got: {:?}",
methods
);
}
#[tokio::test]
async fn test_casts_property_produces_typed_virtual_properties() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
protected $casts = [
'is_admin' => 'boolean',
'created_at' => 'datetime',
'options' => 'array',
];
public function test() {
$user = new User();
$user->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 11, 15).await;
let props = property_names(&items);
assert!(
props.contains(&"is_admin"),
"Cast 'boolean' should produce is_admin property, got: {:?}",
props
);
assert!(
props.contains(&"created_at"),
"Cast 'datetime' should produce created_at property, got: {:?}",
props
);
assert!(
props.contains(&"options"),
"Cast 'array' should produce options property, got: {:?}",
props
);
}
#[tokio::test]
async fn test_casts_boolean_type_hint() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
protected $casts = [
'is_admin' => 'boolean',
];
public function test() {
$user = new User();
$user->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 9, 15).await;
let prop = items
.iter()
.find(|i| i.kind == Some(CompletionItemKind::PROPERTY) && i.label == "is_admin");
assert!(prop.is_some(), "should find is_admin property");
assert!(
prop.unwrap()
.detail
.as_deref()
.unwrap_or("")
.contains("bool"),
"is_admin should show bool in detail, got: {:?}",
prop.unwrap().detail
);
}
#[tokio::test]
async fn test_casts_integer_and_float() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
protected $casts = [
'age' => 'integer',
'score' => 'float',
'price' => 'decimal:2',
];
public function test() {
$user = new User();
$user->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 11, 15).await;
let props = property_names(&items);
assert!(props.contains(&"age"), "integer cast, got: {:?}", props);
assert!(props.contains(&"score"), "float cast, got: {:?}", props);
assert!(props.contains(&"price"), "decimal:2 cast, got: {:?}", props);
}
#[tokio::test]
async fn test_casts_string_and_encrypted() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
protected $casts = [
'name' => 'string',
'secret' => 'encrypted',
'hashed_val' => 'hashed',
];
public function test() {
$user = new User();
$user->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 11, 15).await;
let props = property_names(&items);
assert!(props.contains(&"name"), "string cast, got: {:?}", props);
assert!(
props.contains(&"secret"),
"encrypted cast, got: {:?}",
props
);
assert!(
props.contains(&"hashed_val"),
"hashed cast, got: {:?}",
props
);
}
#[tokio::test]
async fn test_casts_object_and_collection() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
protected $casts = [
'metadata' => 'object',
'tags' => 'collection',
];
public function test() {
$user = new User();
$user->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 10, 15).await;
let props = property_names(&items);
assert!(props.contains(&"metadata"), "object cast, got: {:?}", props);
assert!(props.contains(&"tags"), "collection cast, got: {:?}", props);
}
#[tokio::test]
async fn test_casts_method_produces_typed_virtual_properties() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
protected function casts(): array {
return [
'is_admin' => 'boolean',
'created_at' => 'datetime',
];
}
public function test() {
$user = new User();
$user->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 12, 15).await;
let props = property_names(&items);
assert!(
props.contains(&"is_admin"),
"casts() method should produce is_admin, got: {:?}",
props
);
assert!(
props.contains(&"created_at"),
"casts() method should produce created_at, got: {:?}",
props
);
}
#[tokio::test]
async fn test_casts_coexist_with_relationships_and_scopes() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
protected $casts = [
'is_admin' => 'boolean',
];
/** @return \\Illuminate\\Database\\Eloquent\\Relations\\HasMany<\\App\\Models\\Post, $this> */
public function posts(): mixed { return $this->hasMany(Post::class); }
public function scopeActive(\\Illuminate\\Database\\Eloquent\\Builder $query): void {}
public function test() {
$user = new User();
$user->
}
}
";
let post_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class Post extends Model {
public function getTitle(): string { return ''; }
}
";
let (backend, dir) = make_workspace(&[
("src/Models/User.php", user_php),
("src/Models/Post.php", post_php),
]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 12, 15).await;
let props = property_names(&items);
let methods = method_names(&items);
assert!(
props.contains(&"is_admin"),
"Cast property, got: {:?}",
props
);
assert!(
props.contains(&"posts"),
"Relationship property, got: {:?}",
props
);
assert!(
methods.contains(&"active"),
"Scope method, got: {:?}",
methods
);
}
#[tokio::test]
async fn test_casts_coexist_with_accessors() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
protected $casts = [
'is_admin' => 'boolean',
];
public function getDisplayNameAttribute(): string { return ''; }
public function avatarUrl(): \\Illuminate\\Database\\Eloquent\\Casts\\Attribute {
return \\Illuminate\\Database\\Eloquent\\Casts\\Attribute::make();
}
public function test() {
$user = new User();
$user->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 13, 15).await;
let props = property_names(&items);
assert!(
props.contains(&"is_admin"),
"Cast property, got: {:?}",
props
);
assert!(
props.contains(&"display_name"),
"Legacy accessor, got: {:?}",
props
);
assert!(
props.contains(&"avatar_url"),
"Modern accessor, got: {:?}",
props
);
}
#[tokio::test]
async fn test_casts_on_this_arrow() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
protected $casts = [
'is_admin' => 'boolean',
'options' => 'array',
];
public function demo() {
$this->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 9, 15).await;
let props = property_names(&items);
assert!(
props.contains(&"is_admin"),
"$this-> should show cast properties, got: {:?}",
props
);
assert!(
props.contains(&"options"),
"$this-> should show cast properties, got: {:?}",
props
);
}
#[tokio::test]
async fn test_casts_method_overrides_property_and_both_merge() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
protected $casts = [
'from_property' => 'boolean',
'shared' => 'boolean',
];
protected function casts(): array {
return [
'from_method' => 'integer',
'shared' => 'integer',
];
}
public function test() {
$user = new User();
$user->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 16, 15).await;
let props = property_names(&items);
assert!(
props.contains(&"from_property"),
"$casts-only column should be present, got: {:?}",
props
);
assert!(
props.contains(&"from_method"),
"casts()-only column should be present, got: {:?}",
props
);
assert!(
props.contains(&"shared"),
"overlapping column should be present, got: {:?}",
props
);
let shared = items
.iter()
.find(|i| i.kind == Some(CompletionItemKind::PROPERTY) && i.label == "shared");
assert!(shared.is_some());
assert!(
shared
.unwrap()
.detail
.as_deref()
.unwrap_or("")
.contains("int"),
"shared should be int from casts() not bool from $casts, got: {:?}",
shared.unwrap().detail
);
}
#[tokio::test]
async fn test_casts_non_model_class_no_properties() {
let service_php = "\
<?php
namespace App\\Services;
class UserService {
protected $casts = [
'is_admin' => 'boolean',
];
public function test() {
$svc = new UserService();
$svc->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/UserService.php", service_php)]);
let items = complete_at(
&backend,
&dir,
"src/Models/UserService.php",
service_php,
8,
14,
)
.await;
let props = property_names(&items);
assert!(
!props.contains(&"is_admin"),
"Non-model should not get cast virtual properties, got: {:?}",
props
);
}
#[tokio::test]
async fn test_casts_double_quoted_strings() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
protected $casts = [
\"is_admin\" => \"boolean\",
\"created_at\" => \"datetime\",
];
public function test() {
$user = new User();
$user->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 10, 15).await;
let props = property_names(&items);
assert!(
props.contains(&"is_admin"),
"Double-quoted casts key, got: {:?}",
props
);
assert!(
props.contains(&"created_at"),
"Double-quoted casts key, got: {:?}",
props
);
}
#[tokio::test]
async fn test_casts_cross_file_psr4() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
protected $casts = [
'is_admin' => 'boolean',
'balance' => 'decimal:2',
];
}
";
let controller_php = "\
<?php
namespace App\\Models;
class UserController {
public function show() {
$user = new User();
$user->
}
}
";
let (backend, dir) = make_workspace(&[
("src/Models/User.php", user_php),
("src/Models/UserController.php", controller_php),
]);
let items = complete_at(
&backend,
&dir,
"src/Models/UserController.php",
controller_php,
5,
15,
)
.await;
let props = property_names(&items);
assert!(
props.contains(&"is_admin"),
"Cross-file cast property, got: {:?}",
props
);
assert!(
props.contains(&"balance"),
"Cross-file decimal cast, got: {:?}",
props
);
}
#[tokio::test]
async fn test_casts_indirect_model_subclass() {
let base_model_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class BaseModel extends Model {}
";
let user_php = "\
<?php
namespace App\\Models;
class User extends BaseModel {
protected $casts = [
'is_admin' => 'boolean',
];
public function test() {
$user = new User();
$user->
}
}
";
let (backend, dir) = make_workspace(&[
("src/Models/BaseModel.php", base_model_php),
("src/Models/User.php", user_php),
]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 8, 15).await;
let props = property_names(&items);
assert!(
props.contains(&"is_admin"),
"Indirect model subclass should get cast properties, got: {:?}",
props
);
}
#[tokio::test]
async fn test_casts_same_file_plain_backend() {
let backend = create_test_backend();
let uri = Url::parse("file:///casts_same_file.php").unwrap();
let text = concat!(
"<?php\n",
"namespace Illuminate\\Database\\Eloquent {\n",
" abstract class Model {}\n",
"}\n",
"namespace App\\Models {\n",
" class User extends \\Illuminate\\Database\\Eloquent\\Model {\n",
" protected $casts = [\n",
" 'is_admin' => 'boolean',\n",
" 'created_at' => 'datetime',\n",
" 'options' => 'array',\n",
" ];\n",
" public function test() {\n",
" $user = new User();\n",
" $user->\n",
" }\n",
" }\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position {
line: 13,
character: 19,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.await
.unwrap();
let items = match result {
Some(CompletionResponse::Array(items)) => items,
Some(CompletionResponse::List(list)) => list.items,
_ => Vec::new(),
};
let props = property_names(&items);
assert!(
props.contains(&"is_admin"),
"Same-file cast property, got: {:?}",
props
);
assert!(
props.contains(&"created_at"),
"Same-file cast property, got: {:?}",
props
);
assert!(
props.contains(&"options"),
"Same-file cast property, got: {:?}",
props
);
}
#[tokio::test]
async fn test_casts_custom_cast_class_with_get_method() {
let money_cast_php = "\
<?php
namespace App\\Casts;
class MoneyCast {
public function get($model, string $key, $value, array $attributes): \\App\\ValueObjects\\Money {
return new \\App\\ValueObjects\\Money($value);
}
}
";
let money_php = "\
<?php
namespace App\\ValueObjects;
class Money {
public function amount(): int { return 0; }
public function currency(): string { return ''; }
public function formatted(): string { return ''; }
}
";
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
protected $casts = [
'balance' => 'App\\Casts\\MoneyCast',
];
public function test() {
$user = new User();
$user->
}
}
";
let (backend, dir) = make_workspace(&[
("src/Models/User.php", user_php),
("src/Collections/MoneyCast.php", money_cast_php),
("src/Collections/Money.php", money_php),
]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 9, 15).await;
let props = property_names(&items);
assert!(
props.contains(&"balance"),
"Custom cast class should produce a virtual property, got: {:?}",
props
);
}
#[tokio::test]
async fn test_casts_encrypted_variants() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
protected $casts = [
'secret' => 'encrypted',
'encrypted_opts' => 'encrypted:array',
'encrypted_coll' => 'encrypted:collection',
'encrypted_obj' => 'encrypted:object',
];
public function test() {
$user = new User();
$user->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 12, 15).await;
let props = property_names(&items);
assert!(
props.contains(&"secret"),
"encrypted cast, got: {:?}",
props
);
assert!(
props.contains(&"encrypted_opts"),
"encrypted:array cast, got: {:?}",
props
);
assert!(
props.contains(&"encrypted_coll"),
"encrypted:collection cast, got: {:?}",
props
);
assert!(
props.contains(&"encrypted_obj"),
"encrypted:object cast, got: {:?}",
props
);
}
#[tokio::test]
async fn test_casts_datetime_with_format() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
protected $casts = [
'birthday' => 'date:Y-m-d',
'logged_at' => 'datetime:Y-m-d H:i:s',
'frozen_at' => 'immutable_datetime:Y-m-d',
];
public function test() {
$user = new User();
$user->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 11, 15).await;
let props = property_names(&items);
assert!(
props.contains(&"birthday"),
"date:format cast, got: {:?}",
props
);
assert!(
props.contains(&"logged_at"),
"datetime:format cast, got: {:?}",
props
);
assert!(
props.contains(&"frozen_at"),
"immutable_datetime:format cast, got: {:?}",
props
);
}
#[tokio::test]
async fn test_casts_enum_class_resolves_to_enum_type() {
let status_enum_php = "\
<?php
namespace App\\Enums;
enum Status: string {
case Active = 'active';
case Inactive = 'inactive';
public function label(): string { return $this->value; }
}
";
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
protected $casts = [
'status' => App\\Enums\\Status::class,
];
public function test() {
$user = new User();
$user->
}
}
";
let composer = r#"{
"autoload": {
"psr-4": {
"App\\Models\\": "src/Models/",
"App\\Enums\\": "src/Enums/",
"App\\Collections\\": "src/Collections/",
"App\\Concerns\\": "src/Concerns/",
"Illuminate\\Database\\Eloquent\\": "vendor/illuminate/Eloquent/",
"Illuminate\\Database\\Eloquent\\Attributes\\": "vendor/illuminate/Eloquent/Attributes/",
"Illuminate\\Database\\Eloquent\\Relations\\": "vendor/illuminate/Eloquent/Relations/",
"Illuminate\\Database\\Query\\": "vendor/illuminate/Query/",
"Illuminate\\Database\\Concerns\\": "vendor/illuminate/Concerns/"
}
}
}"#;
let mut files: Vec<(&str, &str)> = framework_stubs();
files.push(("src/Enums/Status.php", status_enum_php));
files.push(("src/Models/User.php", user_php));
let (backend, dir) = create_psr4_workspace(composer, &files);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 9, 15).await;
let props = property_names(&items);
assert!(
props.contains(&"status"),
"Enum cast should produce status property, got: {:?}",
props
);
}
#[tokio::test]
async fn test_casts_castable_class_resolves_to_class_itself() {
let address_php = "\
<?php
namespace App\\Casts;
use Illuminate\\Contracts\\Database\\Eloquent\\Castable;
class Address implements Castable {
public function getStreet(): string { return ''; }
public function getCity(): string { return ''; }
public static function castUsing(array $arguments): mixed { return null; }
}
";
let castable_php = "\
<?php
namespace Illuminate\\Contracts\\Database\\Eloquent;
interface Castable {}
";
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
protected $casts = [
'address' => App\\Casts\\Address::class,
];
public function test() {
$user = new User();
$user->
}
}
";
let composer = r#"{
"autoload": {
"psr-4": {
"App\\Models\\": "src/Models/",
"App\\Casts\\": "src/Casts/",
"App\\Collections\\": "src/Collections/",
"App\\Concerns\\": "src/Concerns/",
"Illuminate\\Database\\Eloquent\\": "vendor/illuminate/Eloquent/",
"Illuminate\\Database\\Eloquent\\Attributes\\": "vendor/illuminate/Eloquent/Attributes/",
"Illuminate\\Database\\Eloquent\\Relations\\": "vendor/illuminate/Eloquent/Relations/",
"Illuminate\\Database\\Query\\": "vendor/illuminate/Query/",
"Illuminate\\Database\\Concerns\\": "vendor/illuminate/Concerns/",
"Illuminate\\Contracts\\Database\\Eloquent\\": "vendor/illuminate/Contracts/"
}
}
}"#;
let mut files: Vec<(&str, &str)> = framework_stubs();
files.push(("vendor/illuminate/Contracts/Castable.php", castable_php));
files.push(("src/Casts/Address.php", address_php));
files.push(("src/Models/User.php", user_php));
let (backend, dir) = create_psr4_workspace(composer, &files);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 9, 15).await;
let props = property_names(&items);
assert!(
props.contains(&"address"),
"Castable class should produce address property, got: {:?}",
props
);
}
#[tokio::test]
async fn test_casts_class_with_colon_argument_suffix() {
let address_php = "\
<?php
namespace App\\Casts;
use Illuminate\\Contracts\\Database\\Eloquent\\Castable;
class Address implements Castable {
public function getStreet(): string { return ''; }
public static function castUsing(array $arguments): mixed { return null; }
}
";
let castable_php = "\
<?php
namespace Illuminate\\Contracts\\Database\\Eloquent;
interface Castable {}
";
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
protected function casts(): array {
return [
'address' => App\\Casts\\Address::class.':nullable',
];
}
public function test() {
$user = new User();
$user->
}
}
";
let composer = r#"{
"autoload": {
"psr-4": {
"App\\Models\\": "src/Models/",
"App\\Casts\\": "src/Casts/",
"App\\Collections\\": "src/Collections/",
"App\\Concerns\\": "src/Concerns/",
"Illuminate\\Database\\Eloquent\\": "vendor/illuminate/Eloquent/",
"Illuminate\\Database\\Eloquent\\Attributes\\": "vendor/illuminate/Eloquent/Attributes/",
"Illuminate\\Database\\Eloquent\\Relations\\": "vendor/illuminate/Eloquent/Relations/",
"Illuminate\\Database\\Query\\": "vendor/illuminate/Query/",
"Illuminate\\Database\\Concerns\\": "vendor/illuminate/Concerns/",
"Illuminate\\Contracts\\Database\\Eloquent\\": "vendor/illuminate/Contracts/"
}
}
}"#;
let mut files: Vec<(&str, &str)> = framework_stubs();
files.push(("vendor/illuminate/Contracts/Castable.php", castable_php));
files.push(("src/Casts/Address.php", address_php));
files.push(("src/Models/User.php", user_php));
let (backend, dir) = create_psr4_workspace(composer, &files);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 11, 15).await;
let props = property_names(&items);
assert!(
props.contains(&"address"),
"::class.':argument' should strip suffix and resolve, got: {:?}",
props
);
}
#[tokio::test]
async fn test_casts_enum_with_colon_argument_in_casts_method() {
let status_enum_php = "\
<?php
namespace App\\Enums;
enum Status: string {
case Active = 'active';
case Inactive = 'inactive';
public function label(): string { return $this->value; }
}
";
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
protected function casts(): array {
return [
'status' => App\\Enums\\Status::class.':force',
];
}
public function test() {
$user = new User();
$user->
}
}
";
let composer = r#"{
"autoload": {
"psr-4": {
"App\\Models\\": "src/Models/",
"App\\Enums\\": "src/Enums/",
"App\\Collections\\": "src/Collections/",
"App\\Concerns\\": "src/Concerns/",
"Illuminate\\Database\\Eloquent\\": "vendor/illuminate/Eloquent/",
"Illuminate\\Database\\Eloquent\\Attributes\\": "vendor/illuminate/Eloquent/Attributes/",
"Illuminate\\Database\\Eloquent\\Relations\\": "vendor/illuminate/Eloquent/Relations/",
"Illuminate\\Database\\Query\\": "vendor/illuminate/Query/",
"Illuminate\\Database\\Concerns\\": "vendor/illuminate/Concerns/"
}
}
}"#;
let mut files: Vec<(&str, &str)> = framework_stubs();
files.push(("src/Enums/Status.php", status_enum_php));
files.push(("src/Models/User.php", user_php));
let (backend, dir) = create_psr4_workspace(composer, &files);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 11, 15).await;
let props = property_names(&items);
assert!(
props.contains(&"status"),
"Enum cast with :argument suffix should produce status property, got: {:?}",
props
);
}
#[tokio::test]
async fn test_casts_enum_and_builtin_coexist() {
let status_enum_php = "\
<?php
namespace App\\Enums;
enum Status: string {
case Active = 'active';
case Inactive = 'inactive';
}
";
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
protected $casts = [
'status' => App\\Enums\\Status::class,
'is_admin' => 'boolean',
'created_at' => 'datetime',
];
public function test() {
$user = new User();
$user->
}
}
";
let composer = r#"{
"autoload": {
"psr-4": {
"App\\Models\\": "src/Models/",
"App\\Enums\\": "src/Enums/",
"App\\Collections\\": "src/Collections/",
"App\\Concerns\\": "src/Concerns/",
"Illuminate\\Database\\Eloquent\\": "vendor/illuminate/Eloquent/",
"Illuminate\\Database\\Eloquent\\Attributes\\": "vendor/illuminate/Eloquent/Attributes/",
"Illuminate\\Database\\Eloquent\\Relations\\": "vendor/illuminate/Eloquent/Relations/",
"Illuminate\\Database\\Query\\": "vendor/illuminate/Query/",
"Illuminate\\Database\\Concerns\\": "vendor/illuminate/Concerns/"
}
}
}"#;
let mut files: Vec<(&str, &str)> = framework_stubs();
files.push(("src/Enums/Status.php", status_enum_php));
files.push(("src/Models/User.php", user_php));
let (backend, dir) = create_psr4_workspace(composer, &files);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 11, 15).await;
let props = property_names(&items);
assert!(
props.contains(&"status"),
"Enum cast property, got: {:?}",
props
);
assert!(
props.contains(&"is_admin"),
"Boolean cast property, got: {:?}",
props
);
assert!(
props.contains(&"created_at"),
"Datetime cast property, got: {:?}",
props
);
}
#[tokio::test]
async fn test_casts_custom_cast_class_get_return_type_resolves_to_class() {
let html_string_php = "\
<?php
namespace Illuminate\\Support;
class HtmlString {
public function toHtml(): string { return ''; }
public function isEmpty(): bool { return true; }
}
";
let html_cast_php = "\
<?php
namespace App\\Casts;
use Illuminate\\Support\\HtmlString;
class HtmlCast {
public function get($model, string $key, $value, array $attributes): ?HtmlString {
return new HtmlString($value);
}
}
";
let brand_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class BrandTranslation extends Model {
protected $casts = [
'description' => 'App\\Casts\\HtmlCast',
];
public function test() {
$brand = new BrandTranslation();
$brand->description->
}
}
";
let composer = r#"{
"autoload": {
"psr-4": {
"App\\Models\\": "src/Models/",
"App\\Casts\\": "src/Casts/",
"App\\Collections\\": "src/Collections/",
"App\\Concerns\\": "src/Concerns/",
"Illuminate\\Support\\": "vendor/illuminate/Support/",
"Illuminate\\Database\\Eloquent\\": "vendor/illuminate/Eloquent/",
"Illuminate\\Database\\Eloquent\\Attributes\\": "vendor/illuminate/Eloquent/Attributes/",
"Illuminate\\Database\\Eloquent\\Relations\\": "vendor/illuminate/Eloquent/Relations/",
"Illuminate\\Database\\Query\\": "vendor/illuminate/Query/",
"Illuminate\\Database\\Concerns\\": "vendor/illuminate/Concerns/"
}
}
}"#;
let mut files: Vec<(&str, &str)> = framework_stubs();
files.push(("vendor/illuminate/Support/HtmlString.php", html_string_php));
files.push(("src/Casts/HtmlCast.php", html_cast_php));
files.push(("src/Models/BrandTranslation.php", brand_php));
let (backend, dir) = create_psr4_workspace(composer, &files);
let items = complete_at(
&backend,
&dir,
"src/Models/BrandTranslation.php",
brand_php,
9,
16,
)
.await;
let props = property_names(&items);
assert!(
props.contains(&"description"),
"Custom cast HtmlCast should produce 'description' property, got: {:?}",
props
);
let items = complete_at(
&backend,
&dir,
"src/Models/BrandTranslation.php",
brand_php,
9,
30,
)
.await;
let methods = method_names(&items);
assert!(
methods.contains(&"toHtml"),
"description should resolve to HtmlString with toHtml(), got methods: {:?}",
methods
);
assert!(
methods.contains(&"isEmpty"),
"description should resolve to HtmlString with isEmpty(), got methods: {:?}",
methods
);
}
#[tokio::test]
async fn test_casts_custom_cast_class_with_class_string_syntax() {
let html_string_php = "\
<?php
namespace Illuminate\\Support;
class HtmlString {
public function toHtml(): string { return ''; }
}
";
let html_cast_php = "\
<?php
namespace App\\Casts;
use Illuminate\\Support\\HtmlString;
class HtmlCast {
public function get($model, string $key, $value, array $attributes): ?HtmlString {
return new HtmlString($value);
}
}
";
let brand_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use App\\Casts\\HtmlCast;
class BrandTranslation extends Model {
protected $casts = [
'description' => HtmlCast::class,
];
public function test() {
$brand = new BrandTranslation();
$brand->description->
}
}
";
let composer = r#"{
"autoload": {
"psr-4": {
"App\\Models\\": "src/Models/",
"App\\Casts\\": "src/Casts/",
"App\\Collections\\": "src/Collections/",
"App\\Concerns\\": "src/Concerns/",
"Illuminate\\Support\\": "vendor/illuminate/Support/",
"Illuminate\\Database\\Eloquent\\": "vendor/illuminate/Eloquent/",
"Illuminate\\Database\\Eloquent\\Attributes\\": "vendor/illuminate/Eloquent/Attributes/",
"Illuminate\\Database\\Eloquent\\Relations\\": "vendor/illuminate/Eloquent/Relations/",
"Illuminate\\Database\\Query\\": "vendor/illuminate/Query/",
"Illuminate\\Database\\Concerns\\": "vendor/illuminate/Concerns/"
}
}
}"#;
let mut files: Vec<(&str, &str)> = framework_stubs();
files.push(("vendor/illuminate/Support/HtmlString.php", html_string_php));
files.push(("src/Casts/HtmlCast.php", html_cast_php));
files.push(("src/Models/BrandTranslation.php", brand_php));
let (backend, dir) = create_psr4_workspace(composer, &files);
let items = complete_at(
&backend,
&dir,
"src/Models/BrandTranslation.php",
brand_php,
10,
16,
)
.await;
let props = property_names(&items);
assert!(
props.contains(&"description"),
"HtmlCast::class syntax should produce 'description' property, got: {:?}",
props
);
let items = complete_at(
&backend,
&dir,
"src/Models/BrandTranslation.php",
brand_php,
10,
30,
)
.await;
let methods = method_names(&items);
assert!(
methods.contains(&"toHtml"),
"description via ::class should resolve to HtmlString with toHtml(), got: {:?}",
methods
);
}
#[tokio::test]
async fn test_casts_custom_cast_class_no_native_return_type_uses_docblock() {
let html_string_php = "\
<?php
namespace Illuminate\\Support;
class HtmlString {
public function toHtml(): string { return ''; }
public function isEmpty(): bool { return true; }
}
";
let html_cast_php = "\
<?php
namespace App\\Casts;
use Illuminate\\Support\\HtmlString;
class HtmlCast {
/** @return HtmlString|null */
public function get($model, string $key, $value, array $attributes) {
return new HtmlString($value);
}
}
";
let brand_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class BrandTranslation extends Model {
protected $casts = [
'description' => 'App\\Casts\\HtmlCast',
];
public function test() {
$brand = new BrandTranslation();
$brand->description->
}
}
";
let composer = r#"{
"autoload": {
"psr-4": {
"App\\Models\\": "src/Models/",
"App\\Casts\\": "src/Casts/",
"App\\Collections\\": "src/Collections/",
"App\\Concerns\\": "src/Concerns/",
"Illuminate\\Support\\": "vendor/illuminate/Support/",
"Illuminate\\Database\\Eloquent\\": "vendor/illuminate/Eloquent/",
"Illuminate\\Database\\Eloquent\\Attributes\\": "vendor/illuminate/Eloquent/Attributes/",
"Illuminate\\Database\\Eloquent\\Relations\\": "vendor/illuminate/Eloquent/Relations/",
"Illuminate\\Database\\Query\\": "vendor/illuminate/Query/",
"Illuminate\\Database\\Concerns\\": "vendor/illuminate/Concerns/"
}
}
}"#;
let mut files: Vec<(&str, &str)> = framework_stubs();
files.push(("vendor/illuminate/Support/HtmlString.php", html_string_php));
files.push(("src/Casts/HtmlCast.php", html_cast_php));
files.push(("src/Models/BrandTranslation.php", brand_php));
let (backend, dir) = create_psr4_workspace(composer, &files);
let items = complete_at(
&backend,
&dir,
"src/Models/BrandTranslation.php",
brand_php,
9,
16,
)
.await;
let props = property_names(&items);
assert!(
props.contains(&"description"),
"Custom cast with @return docblock should produce 'description' property, got: {:?}",
props
);
let items = complete_at(
&backend,
&dir,
"src/Models/BrandTranslation.php",
brand_php,
9,
30,
)
.await;
let methods = method_names(&items);
assert!(
methods.contains(&"toHtml"),
"description via @return docblock should resolve to HtmlString with toHtml(), got: {:?}",
methods
);
assert!(
methods.contains(&"isEmpty"),
"description via @return docblock should resolve to HtmlString with isEmpty(), got: {:?}",
methods
);
}
#[tokio::test]
async fn test_casts_custom_cast_implements_generic_interface_no_native_return() {
let html_string_php = "\
<?php
namespace Illuminate\\Support;
class HtmlString {
public function toHtml(): string { return ''; }
public function isEmpty(): bool { return true; }
}
";
let casts_attributes_php = "\
<?php
namespace Illuminate\\Contracts\\Database\\Eloquent;
/**
* @template TGet
* @template TSet
*/
interface CastsAttributes {
/**
* @return TGet|null
*/
public function get($model, string $key, $value, array $attributes);
}
";
let html_cast_php = "\
<?php
namespace App\\Casts;
use Illuminate\\Support\\HtmlString;
use Illuminate\\Contracts\\Database\\Eloquent\\CastsAttributes;
/**
* @implements CastsAttributes<HtmlString, HtmlString>
*/
final class HtmlCast implements CastsAttributes {
public function get($model, string $key, $value, array $attributes) {
return new HtmlString($value);
}
}
";
let brand_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class BrandTranslation extends Model {
protected $casts = [
'description' => 'App\\Casts\\HtmlCast',
];
public function test() {
$brand = new BrandTranslation();
$brand->description->
}
}
";
let composer = r#"{
"autoload": {
"psr-4": {
"App\\Models\\": "src/Models/",
"App\\Casts\\": "src/Casts/",
"App\\Collections\\": "src/Collections/",
"App\\Concerns\\": "src/Concerns/",
"Illuminate\\Support\\": "vendor/illuminate/Support/",
"Illuminate\\Contracts\\Database\\Eloquent\\": "vendor/illuminate/Contracts/",
"Illuminate\\Database\\Eloquent\\": "vendor/illuminate/Eloquent/",
"Illuminate\\Database\\Eloquent\\Attributes\\": "vendor/illuminate/Eloquent/Attributes/",
"Illuminate\\Database\\Eloquent\\Relations\\": "vendor/illuminate/Eloquent/Relations/",
"Illuminate\\Database\\Query\\": "vendor/illuminate/Query/",
"Illuminate\\Database\\Concerns\\": "vendor/illuminate/Concerns/"
}
}
}"#;
let mut files: Vec<(&str, &str)> = framework_stubs();
files.push(("vendor/illuminate/Support/HtmlString.php", html_string_php));
files.push((
"vendor/illuminate/Contracts/CastsAttributes.php",
casts_attributes_php,
));
files.push(("src/Casts/HtmlCast.php", html_cast_php));
files.push(("src/Models/BrandTranslation.php", brand_php));
let (backend, dir) = create_psr4_workspace(composer, &files);
let items = complete_at(
&backend,
&dir,
"src/Models/BrandTranslation.php",
brand_php,
9,
16,
)
.await;
let props = property_names(&items);
assert!(
props.contains(&"description"),
"@implements CastsAttributes<HtmlString, HtmlString> should produce 'description' property, got: {:?}",
props
);
let items = complete_at(
&backend,
&dir,
"src/Models/BrandTranslation.php",
brand_php,
9,
30,
)
.await;
let methods = method_names(&items);
assert!(
methods.contains(&"toHtml"),
"description should resolve to HtmlString via @implements generic, got methods: {:?}",
methods
);
assert!(
methods.contains(&"isEmpty"),
"description should resolve to HtmlString via @implements generic, got methods: {:?}",
methods
);
}
#[tokio::test]
async fn test_attributes_defaults_produce_typed_virtual_properties() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
protected $attributes = [
'role' => 'user',
'is_active' => true,
'login_count' => 0,
];
public function test() {
$user = new User();
$user->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 11, 15).await;
let props = property_names(&items);
assert!(
props.contains(&"role"),
"Attribute default 'user' (string) should produce role property, got: {:?}",
props
);
assert!(
props.contains(&"is_active"),
"Attribute default true (bool) should produce is_active property, got: {:?}",
props
);
assert!(
props.contains(&"login_count"),
"Attribute default 0 (int) should produce login_count property, got: {:?}",
props
);
}
#[tokio::test]
async fn test_attributes_defaults_string_type_hint() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
protected $attributes = [
'role' => 'user',
];
public function test() {
$user = new User();
$user->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 9, 15).await;
let prop = items
.iter()
.find(|i| i.kind == Some(CompletionItemKind::PROPERTY) && i.label == "role");
assert!(prop.is_some(), "should find role property");
assert!(
prop.unwrap()
.detail
.as_deref()
.unwrap_or("")
.contains("string"),
"role should show string in detail, got: {:?}",
prop.unwrap().detail
);
}
#[tokio::test]
async fn test_attributes_defaults_bool_type_hint() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
protected $attributes = [
'is_active' => true,
];
public function test() {
$user = new User();
$user->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 9, 15).await;
let prop = items
.iter()
.find(|i| i.kind == Some(CompletionItemKind::PROPERTY) && i.label == "is_active");
assert!(prop.is_some(), "should find is_active property");
assert!(
prop.unwrap()
.detail
.as_deref()
.unwrap_or("")
.contains("bool"),
"is_active should show bool in detail, got: {:?}",
prop.unwrap().detail
);
}
#[tokio::test]
async fn test_attributes_defaults_int_and_float() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
protected $attributes = [
'login_count' => 0,
'rating' => 1.5,
];
public function test() {
$user = new User();
$user->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 10, 15).await;
let props = property_names(&items);
assert!(
props.contains(&"login_count"),
"int attribute default, got: {:?}",
props
);
assert!(
props.contains(&"rating"),
"float attribute default, got: {:?}",
props
);
}
#[tokio::test]
async fn test_attributes_defaults_null_and_array() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
protected $attributes = [
'bio' => null,
'settings' => [],
];
public function test() {
$user = new User();
$user->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 10, 15).await;
let props = property_names(&items);
assert!(
props.contains(&"bio"),
"null attribute default, got: {:?}",
props
);
assert!(
props.contains(&"settings"),
"array attribute default, got: {:?}",
props
);
}
#[tokio::test]
async fn test_attributes_defaults_casts_take_priority() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
protected $casts = [
'is_active' => 'boolean',
];
protected $attributes = [
'is_active' => 1,
'role' => 'user',
];
public function test() {
$user = new User();
$user->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 13, 15).await;
let props = property_names(&items);
assert!(
props.contains(&"is_active"),
"is_active should be present, got: {:?}",
props
);
assert!(
props.contains(&"role"),
"role should be present from attributes, got: {:?}",
props
);
let prop = items
.iter()
.find(|i| i.kind == Some(CompletionItemKind::PROPERTY) && i.label == "is_active");
assert!(prop.is_some());
assert!(
prop.unwrap()
.detail
.as_deref()
.unwrap_or("")
.contains("bool"),
"is_active should be bool from casts, not int from attributes, got: {:?}",
prop.unwrap().detail
);
}
#[tokio::test]
async fn test_attributes_defaults_on_this_arrow() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
protected $attributes = [
'role' => 'user',
'is_active' => true,
];
public function test() {
$this->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 9, 15).await;
let props = property_names(&items);
assert!(
props.contains(&"role"),
"$this-> should include attribute default property, got: {:?}",
props
);
assert!(
props.contains(&"is_active"),
"$this-> should include attribute default property, got: {:?}",
props
);
}
#[tokio::test]
async fn test_attributes_defaults_cross_file_psr4() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
protected $attributes = [
'role' => 'user',
'login_count' => 0,
];
}
";
let controller_php = "\
<?php
namespace App\\Models;
class UserController {
public function show() {
$user = new User();
$user->
}
}
";
let (backend, dir) = make_workspace(&[
("src/Models/User.php", user_php),
("src/Models/UserController.php", controller_php),
]);
let items = complete_at(
&backend,
&dir,
"src/Models/UserController.php",
controller_php,
5,
15,
)
.await;
let props = property_names(&items);
assert!(
props.contains(&"role"),
"Cross-file attribute default property, got: {:?}",
props
);
assert!(
props.contains(&"login_count"),
"Cross-file attribute default property, got: {:?}",
props
);
}
#[tokio::test]
async fn test_attributes_defaults_coexist_with_relationships_and_scopes() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
protected $attributes = [
'role' => 'user',
];
/** @return \\Illuminate\\Database\\Eloquent\\Relations\\HasMany<Post, $this> */
public function posts(): mixed { return $this->hasMany(Post::class); }
public function scopeActive($query): void {}
public function test() {
$user = new User();
$user->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 12, 15).await;
let props = property_names(&items);
let methods = method_names(&items);
assert!(
props.contains(&"role"),
"attribute default property, got: {:?}",
props
);
assert!(
props.contains(&"posts"),
"relationship property, got: {:?}",
props
);
assert!(
methods.contains(&"active"),
"scope method, got: {:?}",
methods
);
}
#[tokio::test]
async fn test_attributes_defaults_double_quoted_keys() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
protected $attributes = [
\"role\" => \"user\",
\"is_active\" => false,
];
public function test() {
$user = new User();
$user->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 10, 15).await;
let props = property_names(&items);
assert!(
props.contains(&"role"),
"double-quoted key attribute default, got: {:?}",
props
);
assert!(
props.contains(&"is_active"),
"double-quoted key attribute default, got: {:?}",
props
);
}
#[tokio::test]
async fn test_attributes_defaults_negative_numbers() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
protected $attributes = [
'balance' => -100,
'score' => -1.5,
];
public function test() {
$user = new User();
$user->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 10, 15).await;
let props = property_names(&items);
assert!(
props.contains(&"balance"),
"negative int attribute default, got: {:?}",
props
);
assert!(
props.contains(&"score"),
"negative float attribute default, got: {:?}",
props
);
}
#[tokio::test]
async fn test_fillable_produces_mixed_virtual_properties() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
protected $fillable = [
'name',
'email',
'password',
];
public function test() {
$user = new User();
$user->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 11, 15).await;
let props = property_names(&items);
assert!(
props.contains(&"name"),
"$fillable should produce name property, got: {:?}",
props
);
assert!(
props.contains(&"email"),
"$fillable should produce email property, got: {:?}",
props
);
assert!(
props.contains(&"password"),
"$fillable should produce password property, got: {:?}",
props
);
}
#[tokio::test]
async fn test_fillable_type_is_mixed() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
protected $fillable = ['name'];
public function test() {
$user = new User();
$user->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 7, 15).await;
let prop = items
.iter()
.find(|i| i.kind == Some(CompletionItemKind::PROPERTY) && i.label == "name");
assert!(prop.is_some(), "should find name property");
assert!(
prop.unwrap()
.detail
.as_deref()
.unwrap_or("")
.contains("mixed"),
"name from $fillable should show mixed in detail, got: {:?}",
prop.unwrap().detail
);
}
#[tokio::test]
async fn test_guarded_produces_mixed_virtual_properties() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
protected $guarded = [
'id',
'created_at',
];
public function test() {
$user = new User();
$user->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 10, 15).await;
let props = property_names(&items);
assert!(
props.contains(&"id"),
"$guarded should produce id property, got: {:?}",
props
);
assert!(
props.contains(&"created_at"),
"$guarded should produce created_at property, got: {:?}",
props
);
}
#[tokio::test]
async fn test_hidden_produces_mixed_virtual_properties() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
protected $hidden = [
'password',
'remember_token',
];
public function test() {
$user = new User();
$user->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 10, 15).await;
let props = property_names(&items);
assert!(
props.contains(&"password"),
"$hidden should produce password property, got: {:?}",
props
);
assert!(
props.contains(&"remember_token"),
"$hidden should produce remember_token property, got: {:?}",
props
);
}
#[tokio::test]
async fn test_visible_produces_mixed_virtual_properties() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
protected $visible = [
'name',
'avatar_url',
];
public function test() {
$user = new User();
$user->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 10, 15).await;
let props = property_names(&items);
assert!(
props.contains(&"name"),
"$visible should produce name property, got: {:?}",
props
);
assert!(
props.contains(&"avatar_url"),
"$visible should produce avatar_url property, got: {:?}",
props
);
}
#[tokio::test]
async fn test_fillable_guarded_hidden_merge_without_duplicates() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
protected $fillable = ['name', 'email'];
protected $guarded = ['id'];
protected $hidden = ['password', 'email'];
public function test() {
$user = new User();
$user->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 9, 15).await;
let props = property_names(&items);
assert!(props.contains(&"name"), "from $fillable, got: {:?}", props);
assert!(
props.contains(&"email"),
"from $fillable (first), got: {:?}",
props
);
assert!(props.contains(&"id"), "from $guarded, got: {:?}", props);
assert!(
props.contains(&"password"),
"from $hidden, got: {:?}",
props
);
let email_count = items
.iter()
.filter(|i| {
i.kind == Some(CompletionItemKind::PROPERTY)
&& (i.filter_text.as_deref().unwrap_or(&i.label) == "email")
})
.count();
assert_eq!(
email_count, 1,
"email should appear exactly once despite being in both $fillable and $hidden"
);
}
#[tokio::test]
async fn test_casts_take_priority_over_fillable() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
protected $casts = [
'is_admin' => 'boolean',
];
protected $fillable = [
'is_admin',
'name',
];
public function test() {
$user = new User();
$user->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 13, 15).await;
let props = property_names(&items);
assert!(
props.contains(&"is_admin"),
"should be present, got: {:?}",
props
);
assert!(
props.contains(&"name"),
"should be present, got: {:?}",
props
);
let prop = items
.iter()
.find(|i| i.kind == Some(CompletionItemKind::PROPERTY) && i.label == "is_admin");
assert!(prop.is_some());
assert!(
prop.unwrap()
.detail
.as_deref()
.unwrap_or("")
.contains("bool"),
"is_admin should be bool from casts, not mixed from fillable, got: {:?}",
prop.unwrap().detail
);
}
#[tokio::test]
async fn test_attributes_take_priority_over_fillable() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
protected $attributes = [
'role' => 'user',
];
protected $fillable = [
'role',
'email',
];
public function test() {
$user = new User();
$user->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 13, 15).await;
let props = property_names(&items);
assert!(
props.contains(&"role"),
"should be present, got: {:?}",
props
);
assert!(
props.contains(&"email"),
"should be present, got: {:?}",
props
);
let prop = items
.iter()
.find(|i| i.kind == Some(CompletionItemKind::PROPERTY) && i.label == "role");
assert!(prop.is_some());
assert!(
prop.unwrap()
.detail
.as_deref()
.unwrap_or("")
.contains("string"),
"role should be string from attributes, not mixed from fillable, got: {:?}",
prop.unwrap().detail
);
}
#[tokio::test]
async fn test_all_three_sources_coexist() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
protected $casts = [
'is_admin' => 'boolean',
];
protected $attributes = [
'role' => 'user',
];
protected $fillable = [
'is_admin',
'role',
'email',
];
public function test() {
$user = new User();
$user->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 17, 15).await;
let props = property_names(&items);
assert!(props.contains(&"is_admin"), "from casts, got: {:?}", props);
assert!(props.contains(&"role"), "from attributes, got: {:?}", props);
assert!(props.contains(&"email"), "from fillable, got: {:?}", props);
let is_admin = items
.iter()
.find(|i| i.kind == Some(CompletionItemKind::PROPERTY) && i.label == "is_admin")
.unwrap();
assert!(
is_admin.detail.as_deref().unwrap_or("").contains("bool"),
"is_admin should be bool from casts, got: {:?}",
is_admin.detail
);
let role = items
.iter()
.find(|i| i.kind == Some(CompletionItemKind::PROPERTY) && i.label == "role")
.unwrap();
assert!(
role.detail.as_deref().unwrap_or("").contains("string"),
"role should be string from attributes, got: {:?}",
role.detail
);
let email = items
.iter()
.find(|i| i.kind == Some(CompletionItemKind::PROPERTY) && i.label == "email")
.unwrap();
assert!(
email.detail.as_deref().unwrap_or("").contains("mixed"),
"email should be mixed from fillable, got: {:?}",
email.detail
);
}
#[tokio::test]
async fn test_fillable_on_this_arrow() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
protected $fillable = ['name', 'email'];
public function test() {
$this->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 6, 15).await;
let props = property_names(&items);
assert!(
props.contains(&"name"),
"$this-> should include fillable property, got: {:?}",
props
);
assert!(
props.contains(&"email"),
"$this-> should include fillable property, got: {:?}",
props
);
}
#[tokio::test]
async fn test_fillable_cross_file_psr4() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
protected $fillable = ['name', 'email'];
}
";
let controller_php = "\
<?php
namespace App\\Models;
class UserController {
public function show() {
$user = new User();
$user->
}
}
";
let (backend, dir) = make_workspace(&[
("src/Models/User.php", user_php),
("src/Models/UserController.php", controller_php),
]);
let items = complete_at(
&backend,
&dir,
"src/Models/UserController.php",
controller_php,
5,
15,
)
.await;
let props = property_names(&items);
assert!(
props.contains(&"name"),
"Cross-file fillable property, got: {:?}",
props
);
assert!(
props.contains(&"email"),
"Cross-file fillable property, got: {:?}",
props
);
}
#[tokio::test]
async fn test_fillable_coexist_with_relationships_and_scopes() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
protected $fillable = ['name'];
/** @return \\Illuminate\\Database\\Eloquent\\Relations\\HasMany<Post, $this> */
public function posts(): mixed { return $this->hasMany(Post::class); }
public function scopeActive($query): void {}
public function test() {
$user = new User();
$user->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 10, 15).await;
let props = property_names(&items);
let methods = method_names(&items);
assert!(
props.contains(&"name"),
"fillable property, got: {:?}",
props
);
assert!(
props.contains(&"posts"),
"relationship property, got: {:?}",
props
);
assert!(
methods.contains(&"active"),
"scope method, got: {:?}",
methods
);
}
#[tokio::test]
async fn test_appends_produces_mixed_virtual_properties() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
protected $appends = [
'full_name',
'is_verified',
];
public function test() {
$user = new User();
$user->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 10, 15).await;
let props = property_names(&items);
assert!(
props.contains(&"full_name"),
"$appends should produce full_name property, got: {:?}",
props
);
assert!(
props.contains(&"is_verified"),
"$appends should produce is_verified property, got: {:?}",
props
);
let prop = items
.iter()
.find(|i| i.kind == Some(CompletionItemKind::PROPERTY) && i.label == "full_name");
assert!(
prop.unwrap()
.detail
.as_deref()
.unwrap_or("")
.contains("mixed"),
"full_name from $appends should show mixed in detail, got: {:?}",
prop.unwrap().detail
);
}
#[tokio::test]
async fn test_appends_merges_with_fillable_without_duplicates() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
protected $fillable = ['name', 'email'];
protected $appends = ['full_name', 'email'];
public function test() {
$user = new User();
$user->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 8, 15).await;
let props = property_names(&items);
assert!(props.contains(&"name"), "from $fillable, got: {:?}", props);
assert!(
props.contains(&"full_name"),
"from $appends, got: {:?}",
props
);
let email_count = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::PROPERTY) && i.label == "email")
.count();
assert_eq!(
email_count, 1,
"email should appear exactly once despite being in both $fillable and $appends"
);
}
#[tokio::test]
async fn test_casts_take_priority_over_appends() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
protected $casts = [
'is_verified' => 'boolean',
];
protected $appends = [
'is_verified',
'full_name',
];
public function test() {
$user = new User();
$user->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 13, 15).await;
let props = property_names(&items);
assert!(
props.contains(&"is_verified"),
"is_verified should appear, got: {:?}",
props
);
assert!(
props.contains(&"full_name"),
"full_name should appear from $appends, got: {:?}",
props
);
let prop = items
.iter()
.find(|i| i.kind == Some(CompletionItemKind::PROPERTY) && i.label == "is_verified")
.unwrap();
assert!(
prop.detail.as_deref().unwrap_or("").contains("bool"),
"is_verified should be bool from casts, not mixed from appends, got: {:?}",
prop.detail
);
}
#[tokio::test]
async fn test_factory_method_appears_on_model_static_access() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
class User extends Model {
use HasFactory;
}
";
let factory_php = "\
<?php
namespace Database\\Factories;
use Illuminate\\Database\\Eloquent\\Factories\\Factory;
class UserFactory extends Factory {
public function definition(): array { return []; }
}
";
let (backend, dir) = make_workspace(&[
("src/Models/User.php", user_php),
("database/factories/UserFactory.php", factory_php),
]);
let items = complete_at(
&backend,
&dir,
"src/test.php",
"<?php\nuse App\\Models\\User;\nUser::\n",
2,
6,
)
.await;
let methods = method_names(&items);
assert!(
methods.contains(&"factory"),
"factory() should appear as static method on User::, got methods: {:?}",
methods
);
}
#[tokio::test]
async fn test_factory_convention_based_factory_method_on_model() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
class User extends Model {
use HasFactory;
}
";
let factory_php = "\
<?php
namespace Database\\Factories;
use Illuminate\\Database\\Eloquent\\Factories\\Factory;
class UserFactory extends Factory {
public function definition(): array { return []; }
}
";
let (backend, dir) = make_workspace(&[
("src/Models/User.php", user_php),
("database/factories/UserFactory.php", factory_php),
]);
let items = complete_at(
&backend,
&dir,
"src/test.php",
concat!(
"<?php\n",
"use App\\Models\\User;\n",
"function test() {\n",
" User::factory()->\n",
"}\n",
),
3,
22,
)
.await;
let methods = method_names(&items);
assert!(
methods.contains(&"definition"),
"factory() should resolve to UserFactory, got methods: {:?}",
methods
);
}
#[tokio::test]
async fn test_factory_convention_based_create_returns_model() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
class User extends Model {
use HasFactory;
public function greet(): string { return ''; }
}
";
let factory_php = "\
<?php
namespace Database\\Factories;
use Illuminate\\Database\\Eloquent\\Factories\\Factory;
class UserFactory extends Factory {
public function definition(): array { return []; }
}
";
let (backend, dir) = make_workspace(&[
("src/Models/User.php", user_php),
("database/factories/UserFactory.php", factory_php),
]);
let items = complete_at(
&backend,
&dir,
"src/test.php",
"<?php\nuse App\\Models\\User;\nUser::factory()->create()->\n",
2,
28,
)
.await;
let methods = method_names(&items);
assert!(
methods.contains(&"greet"),
"create() should resolve back to User, got methods: {:?}",
methods
);
}
#[tokio::test]
async fn test_factory_convention_based_make_returns_model() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
class User extends Model {
use HasFactory;
public function greet(): string { return ''; }
}
";
let factory_php = "\
<?php
namespace Database\\Factories;
use Illuminate\\Database\\Eloquent\\Factories\\Factory;
class UserFactory extends Factory {
public function definition(): array { return []; }
}
";
let (backend, dir) = make_workspace(&[
("src/Models/User.php", user_php),
("database/factories/UserFactory.php", factory_php),
]);
let items = complete_at(
&backend,
&dir,
"src/test.php",
"<?php\nuse App\\Models\\User;\nUser::factory()->make()->\n",
2,
26,
)
.await;
let methods = method_names(&items);
assert!(
methods.contains(&"greet"),
"make() should resolve back to User, got methods: {:?}",
methods
);
}
#[tokio::test]
async fn test_factory_convention_based_chain_count_then_create() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
class User extends Model {
use HasFactory;
public function greet(): string { return ''; }
}
";
let factory_php = "\
<?php
namespace Database\\Factories;
use Illuminate\\Database\\Eloquent\\Factories\\Factory;
class UserFactory extends Factory {
public function definition(): array { return []; }
}
";
let (backend, dir) = make_workspace(&[
("src/Models/User.php", user_php),
("database/factories/UserFactory.php", factory_php),
]);
let items = complete_at(
&backend,
&dir,
"src/test.php",
"<?php\nuse App\\Models\\User;\nUser::factory()->count(3)->create()->\n",
2,
38,
)
.await;
let methods = method_names(&items);
assert!(
methods.contains(&"greet"),
"count()->create() chain should resolve back to User, got methods: {:?}",
methods
);
}
#[tokio::test]
async fn test_factory_skips_convention_when_use_generic_present() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
use Database\\Factories\\CustomUserFactory;
/**
* @use HasFactory<CustomUserFactory>
*/
class User extends Model {
use HasFactory;
}
";
let custom_factory_php = "\
<?php
namespace Database\\Factories;
use Illuminate\\Database\\Eloquent\\Factories\\Factory;
class CustomUserFactory extends Factory {
public function customMethod(): void {}
public function definition(): array { return []; }
}
";
let (backend, dir) = make_workspace(&[
("src/Models/User.php", user_php),
(
"database/factories/CustomUserFactory.php",
custom_factory_php,
),
]);
let items = complete_at(
&backend,
&dir,
"src/test.php",
"<?php\nuse App\\Models\\User;\nUser::factory()->\n",
2,
18,
)
.await;
let methods = method_names(&items);
assert!(
methods.contains(&"customMethod"),
"should resolve to CustomUserFactory via generics, got methods: {:?}",
methods
);
}
#[tokio::test]
async fn test_factory_no_factory_class_no_crash() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
class User extends Model {
use HasFactory;
public function greet(): string { return ''; }
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(
&backend,
&dir,
"src/test.php",
"<?php\nuse App\\Models\\User;\n$u = new User();\n$u->\n",
3,
4,
)
.await;
let methods = method_names(&items);
assert!(
methods.contains(&"greet"),
"model should still work when factory is missing, got methods: {:?}",
methods
);
}
#[tokio::test]
async fn test_factory_provider_on_factory_class_directly() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
public function greet(): string { return ''; }
}
";
let factory_php = "\
<?php
namespace Database\\Factories;
use Illuminate\\Database\\Eloquent\\Factories\\Factory;
class UserFactory extends Factory {
public function definition(): array { return []; }
}
";
let (backend, dir) = make_workspace(&[
("src/Models/User.php", user_php),
("database/factories/UserFactory.php", factory_php),
]);
let items = complete_at(
&backend,
&dir,
"src/test.php",
concat!(
"<?php\n",
"use Database\\Factories\\UserFactory;\n",
"$f = new UserFactory();\n",
"$f->create()->\n",
),
3,
14,
)
.await;
let methods = method_names(&items);
assert!(
methods.contains(&"greet"),
"create() on factory instance should resolve to User model, got methods: {:?}",
methods
);
}
#[tokio::test]
async fn test_factory_provider_skipped_when_extends_generic_present() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
public function greet(): string { return ''; }
}
";
let factory_php = "\
<?php
namespace Database\\Factories;
use Illuminate\\Database\\Eloquent\\Factories\\Factory;
use App\\Models\\User;
/**
* @extends Factory<User>
*/
class UserFactory extends Factory {
public function definition(): array { return []; }
}
";
let (backend, dir) = make_workspace(&[
("src/Models/User.php", user_php),
("database/factories/UserFactory.php", factory_php),
]);
let items = complete_at(
&backend,
&dir,
"src/test.php",
concat!(
"<?php\n",
"use Database\\Factories\\UserFactory;\n",
"$f = new UserFactory();\n",
"$f->create()->\n",
),
3,
14,
)
.await;
let methods = method_names(&items);
assert!(
methods.contains(&"greet"),
"create() should resolve via @extends generic, got methods: {:?}",
methods
);
}
#[tokio::test]
async fn test_factory_subdirectory_convention() {
let user_php = "\
<?php
namespace App\\Models\\Admin;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
class SuperUser extends Model {
use HasFactory;
public function adminAction(): void {}
}
";
let factory_php = "\
<?php
namespace Database\\Factories\\Admin;
use Illuminate\\Database\\Eloquent\\Factories\\Factory;
class SuperUserFactory extends Factory {
public function definition(): array { return []; }
}
";
let composer = r#"{
"autoload": {
"psr-4": {
"App\\Models\\": "src/Models/",
"App\\Models\\Admin\\": "src/Models/Admin/",
"Database\\Factories\\": "database/factories/",
"Database\\Factories\\Admin\\": "database/factories/Admin/",
"Illuminate\\Database\\Eloquent\\": "vendor/illuminate/Eloquent/",
"Illuminate\\Database\\Eloquent\\Attributes\\": "vendor/illuminate/Eloquent/Attributes/",
"Illuminate\\Database\\Eloquent\\Factories\\": "vendor/illuminate/Eloquent/Factories/",
"Illuminate\\Database\\Eloquent\\Relations\\": "vendor/illuminate/Eloquent/Relations/",
"Illuminate\\Database\\Query\\": "vendor/illuminate/Query/",
"Illuminate\\Database\\Concerns\\": "vendor/illuminate/Concerns/"
}
}
}"#;
let mut files: Vec<(&str, &str)> = framework_stubs();
files.push(("src/Models/Admin/SuperUser.php", user_php));
files.push(("database/factories/Admin/SuperUserFactory.php", factory_php));
let (backend, dir) = create_psr4_workspace(composer, &files);
let items = complete_at(
&backend,
&dir,
"src/test.php",
concat!(
"<?php\n",
"use App\\Models\\Admin\\SuperUser;\n",
"function test() {\n",
" SuperUser::factory()->\n",
"}\n",
),
3,
26,
)
.await;
let methods = method_names(&items);
assert!(
methods.contains(&"definition"),
"factory() on subdirectory model should resolve to SuperUserFactory, got methods: {:?}",
methods
);
}
#[tokio::test]
async fn test_factory_variable_assignment_then_create() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
class User extends Model {
use HasFactory;
public function greet(): string { return ''; }
}
";
let factory_php = "\
<?php
namespace Database\\Factories;
use Illuminate\\Database\\Eloquent\\Factories\\Factory;
class UserFactory extends Factory {
public function definition(): array { return []; }
}
";
let (backend, dir) = make_workspace(&[
("src/Models/User.php", user_php),
("database/factories/UserFactory.php", factory_php),
]);
let items = complete_at(
&backend,
&dir,
"src/test.php",
concat!(
"<?php\n",
"use App\\Models\\User;\n",
"function test() {\n",
" $user = User::factory()->create();\n",
" $user->\n",
"}\n",
),
4,
11,
)
.await;
let methods = method_names(&items);
assert!(
methods.contains(&"greet"),
"$user assigned from factory()->create() should resolve to User, got methods: {:?}",
methods
);
}
#[tokio::test]
async fn test_factory_coexists_with_relationships_and_scopes() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
class User extends Model {
use HasFactory;
public function greet(): string { return ''; }
/** @return \\Illuminate\\Database\\Eloquent\\Relations\\HasMany<Post, $this> */
public function posts() {}
public function scopeActive($query) {}
}
";
let post_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class Post extends Model {
public function title(): string { return ''; }
}
";
let factory_php = "\
<?php
namespace Database\\Factories;
use Illuminate\\Database\\Eloquent\\Factories\\Factory;
class UserFactory extends Factory {
public function definition(): array { return []; }
}
";
let (backend, dir) = make_workspace(&[
("src/Models/User.php", user_php),
("src/Models/Post.php", post_php),
("database/factories/UserFactory.php", factory_php),
]);
let items = complete_at(
&backend,
&dir,
"src/test.php",
"<?php\nuse App\\Models\\User;\nUser::\n",
2,
6,
)
.await;
let methods = method_names(&items);
assert!(
methods.contains(&"factory"),
"factory() should be available as static method, got methods: {:?}",
methods
);
assert!(
methods.contains(&"active"),
"scope should coexist with factory, got methods: {:?}",
methods
);
}
#[tokio::test]
async fn test_scope_available_after_builder_where_chain() {
let brand_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Builder;
class Brand extends Model {
public function scopeIsActive(Builder $query): void {}
public function test() {
$q = Brand::where('id', 1);
$q->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/Brand.php", brand_php)]);
let items = complete_at(&backend, &dir, "src/Models/Brand.php", brand_php, 8, 12).await;
let methods = method_names(&items);
assert!(
methods.contains(&"isActive"),
"After Brand::where(), ->isActive() scope should be available, got: {:?}",
methods
);
assert!(
methods.contains(&"orderBy"),
"Builder methods should still be available, got: {:?}",
methods
);
assert!(
methods.contains(&"get"),
"Builder get() should still be available, got: {:?}",
methods
);
}
#[tokio::test]
async fn test_scope_available_inside_scope_body() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Builder;
class User extends Model {
public function scopeActive(Builder $query): void {
$query->
}
public function scopeVerified(Builder $query): void {}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 6, 16).await;
let methods = method_names(&items);
assert!(
methods.contains(&"verified"),
"Inside scope body, $query->verified() should be available, got: {:?}",
methods
);
assert!(
methods.contains(&"active"),
"Inside scope body, $query->active() should be available, got: {:?}",
methods
);
assert!(
methods.contains(&"where"),
"Builder where() should still be available inside scope body, got: {:?}",
methods
);
}
#[tokio::test]
async fn test_scope_available_after_inline_builder_chain() {
let brand_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Builder;
class Brand extends Model {
public function scopeIsActive(Builder $query): void {}
public function test() {
Brand::where('id', 1)->isActive()->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/Brand.php", brand_php)]);
let items = complete_at(&backend, &dir, "src/Models/Brand.php", brand_php, 7, 43).await;
let methods = method_names(&items);
assert!(
methods.contains(&"get"),
"After chaining through scope, ->get() should be available, got: {:?}",
methods
);
assert!(
methods.contains(&"orderBy"),
"After chaining through scope, ->orderBy() should be available, got: {:?}",
methods
);
assert!(
methods.contains(&"isActive"),
"After chaining through scope, ->isActive() should still be chainable, got: {:?}",
methods
);
}
#[tokio::test]
async fn test_scope_on_builder_with_multiple_scopes() {
let post_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Builder;
class Post extends Model {
public function scopePublished(Builder $query): void {}
public function scopeDraft(Builder $query): void {}
public function scopeByAuthor(Builder $query, int $authorId): void {}
public function test() {
$q = Post::where('id', 1);
$q->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/Post.php", post_php)]);
let items = complete_at(&backend, &dir, "src/Models/Post.php", post_php, 10, 12).await;
let methods = method_names(&items);
assert!(
methods.contains(&"published"),
"published scope should be available on Builder, got: {:?}",
methods
);
assert!(
methods.contains(&"draft"),
"draft scope should be available on Builder, got: {:?}",
methods
);
assert!(
methods.contains(&"byAuthor"),
"byAuthor scope should be available on Builder, got: {:?}",
methods
);
}
#[tokio::test]
async fn test_scope_on_builder_from_trait() {
let trait_php = "\
<?php
namespace App\\Concerns;
use Illuminate\\Database\\Eloquent\\Builder;
trait SoftDeletesCustom {
public function scopeWithTrashed(Builder $query): void {}
}
";
let order_php = "\
<?php
namespace App\\Models;
use App\\Concerns\\SoftDeletesCustom;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Builder;
class Order extends Model {
use SoftDeletesCustom;
public function scopePending(Builder $query): void {}
public function test() {
$q = Order::where('status', 'pending');
$q->
}
}
";
let (backend, dir) = make_workspace(&[
("src/Concerns/SoftDeletesCustom.php", trait_php),
("src/Models/Order.php", order_php),
]);
let items = complete_at(&backend, &dir, "src/Models/Order.php", order_php, 10, 12).await;
let methods = method_names(&items);
assert!(
methods.contains(&"pending"),
"Own scope pending should be available on Builder, got: {:?}",
methods
);
assert!(
methods.contains(&"withTrashed"),
"Trait scope withTrashed should be available on Builder, got: {:?}",
methods
);
}
#[tokio::test]
async fn test_scope_on_builder_cross_file() {
let product_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Builder;
class Product extends Model {
public function scopeInStock(Builder $query): void {}
public function scopeOnSale(Builder $query): void {}
}
";
let service_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Builder;
class ProductService {
public function test() {
$q = Product::where('active', true);
$q->
}
}
";
let (backend, dir) = make_workspace(&[
("src/Models/Product.php", product_php),
("src/Models/ProductService.php", service_php),
]);
let model_uri = Url::from_file_path(dir.path().join("src/Models/Product.php")).unwrap();
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: model_uri,
language_id: "php".to_string(),
version: 1,
text: product_php.to_string(),
},
})
.await;
let items = complete_at(
&backend,
&dir,
"src/Models/ProductService.php",
service_php,
6,
12,
)
.await;
let methods = method_names(&items);
assert!(
methods.contains(&"inStock"),
"Cross-file scope inStock should be available on Builder, got: {:?}",
methods
);
assert!(
methods.contains(&"onSale"),
"Cross-file scope onSale should be available on Builder, got: {:?}",
methods
);
}
#[tokio::test]
async fn test_scope_not_injected_on_non_eloquent_builder() {
let not_a_model_php = "\
<?php
namespace App\\Models;
class NotAModel {
public function hello(): void {}
}
";
let model_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Builder;
class SomeModel extends Model {
public function scopePopular(Builder $query): void {}
/** @return Builder<NotAModel> */
public function getBadBuilder(): Builder { return new Builder(); }
public function test() {
$q = $this->getBadBuilder();
$q->
}
}
";
let (backend, dir) = make_workspace(&[
("src/Models/NotAModel.php", not_a_model_php),
("src/Models/SomeModel.php", model_php),
]);
let uri = Url::from_file_path(dir.path().join("src/Models/NotAModel.php")).unwrap();
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri,
language_id: "php".to_string(),
version: 1,
text: not_a_model_php.to_string(),
},
})
.await;
let items = complete_at(
&backend,
&dir,
"src/Models/SomeModel.php",
model_php,
10,
12,
)
.await;
let methods = method_names(&items);
assert!(
methods.contains(&"where"),
"Builder methods should be available, got: {:?}",
methods
);
assert!(
!methods.contains(&"popular"),
"Scope from SomeModel should NOT appear on Builder<NotAModel>, got: {:?}",
methods
);
}
#[tokio::test]
async fn test_scope_chain_returns_builder_with_scopes() {
let task_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Builder;
class Task extends Model {
public function scopeUrgent(Builder $query): void {}
public function scopeAssignedTo(Builder $query, int $userId): void {}
public function test() {
$q = Task::where('active', true)->urgent();
$q->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/Task.php", task_php)]);
let items = complete_at(&backend, &dir, "src/Models/Task.php", task_php, 9, 12).await;
let methods = method_names(&items);
assert!(
methods.contains(&"assignedTo"),
"After chaining through urgent(), assignedTo() should be available, got: {:?}",
methods
);
assert!(
methods.contains(&"urgent"),
"After chaining through urgent(), urgent() should still be chainable, got: {:?}",
methods
);
assert!(
methods.contains(&"get"),
"Builder get() should be available after scope chain, got: {:?}",
methods
);
}
#[tokio::test]
async fn test_scope_static_call_starts_chain() {
let brand_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Builder;
class Brand extends Model {
public function scopeIsActive(Builder $query): void {}
public function test() {
Brand::isActive()->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/Brand.php", brand_php)]);
let items = complete_at(&backend, &dir, "src/Models/Brand.php", brand_php, 7, 27).await;
let methods = method_names(&items);
assert!(
methods.contains(&"get"),
"Brand::isActive()-> should offer get(), got: {:?}",
methods
);
assert!(
methods.contains(&"first"),
"Brand::isActive()-> should offer first(), got: {:?}",
methods
);
assert!(
methods.contains(&"isActive"),
"Brand::isActive()-> should offer scope isActive(), got: {:?}",
methods
);
}
#[tokio::test]
async fn test_scope_static_chain_inside_closure() {
let repo_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Builder;
class Article extends Model {
public function scopeWhereIsPublished(Builder $query): void {}
public function scopeWhereLangCode(Builder $query, string $code): void {}
public function scopeOrderByPublishDate(Builder $query): void {}
}
";
let service_php = "\
<?php
namespace App\\Services;
use App\\Models\\Article;
class ArticleService {
public function getLatest(int $limit): mixed {
return Article::whereIsPublished()
->whereLangCode('en')
->orderByPublishDate()
->take($limit)
->get();
}
}
";
let (backend, dir) = make_workspace(&[
("src/Models/Article.php", repo_php),
("src/Services/ArticleService.php", service_php),
]);
let items = complete_at(
&backend,
&dir,
"src/Services/ArticleService.php",
service_php,
7,
15,
)
.await;
let methods = method_names(&items);
assert!(
methods.contains(&"orderByPublishDate"),
"Chain inside closure should offer scope orderByPublishDate(), got: {:?}",
methods
);
assert!(
methods.contains(&"get"),
"Chain inside closure should offer Builder get(), got: {:?}",
methods
);
assert!(
methods.contains(&"first"),
"Chain inside closure should offer Builder first(), got: {:?}",
methods
);
}
#[tokio::test]
async fn test_scope_on_builder_indirect_model_subclass() {
let base_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Builder;
class BaseModel extends Model {
public function scopeTenantAware(Builder $query): void {}
}
";
let invoice_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Builder;
class Invoice extends BaseModel {
public function scopeOverdue(Builder $query): void {}
public function test() {
$q = Invoice::where('status', 'pending');
$q->
}
}
";
let (backend, dir) = make_workspace(&[
("src/Models/BaseModel.php", base_php),
("src/Models/Invoice.php", invoice_php),
]);
let items = complete_at(&backend, &dir, "src/Models/Invoice.php", invoice_php, 7, 12).await;
let methods = method_names(&items);
assert!(
methods.contains(&"overdue"),
"Own scope overdue should be available on Builder, got: {:?}",
methods
);
assert!(
methods.contains(&"tenantAware"),
"Parent model scope tenantAware should be available on Builder, got: {:?}",
methods
);
}
#[tokio::test]
async fn test_scope_strips_query_param_on_builder() {
let item_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Builder;
class Item extends Model {
public function scopeOfType(Builder $query, string $type): void {}
public function test() {
$q = Item::where('active', true);
$q->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/Item.php", item_php)]);
let items = complete_at(&backend, &dir, "src/Models/Item.php", item_php, 8, 12).await;
let of_type_item = items.iter().find(|i| {
let name = i.filter_text.as_deref().unwrap_or(&i.label);
name == "ofType"
});
assert!(
of_type_item.is_some(),
"ofType scope should be available on Builder, got: {:?}",
method_names(&items)
);
}
#[tokio::test]
async fn test_model_with_returns_builder_methods() {
let brand_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Builder;
class Brand extends Model {
public function test() {
Brand::with('english')->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/Brand.php", brand_php)]);
let items = complete_at(&backend, &dir, "src/Models/Brand.php", brand_php, 6, 32).await;
let methods = method_names(&items);
assert!(
!methods.is_empty(),
"Brand::with('english')-> should produce completions, got empty list"
);
assert!(
methods.contains(&"where"),
"Builder::where() should be available after Brand::with(), got: {:?}",
methods
);
assert!(
methods.contains(&"get"),
"Builder::get() should be available after Brand::with(), got: {:?}",
methods
);
}
#[tokio::test]
async fn test_scope_available_after_model_with_call() {
let brand_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Builder;
class Brand extends Model {
public function scopeProductInformation(Builder $query): void {}
public function test() {
Brand::with('english')->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/Brand.php", brand_php)]);
let items = complete_at(&backend, &dir, "src/Models/Brand.php", brand_php, 7, 32).await;
let methods = method_names(&items);
assert!(
methods.contains(&"productInformation"),
"After Brand::with(), ->productInformation() scope should be available, got: {:?}",
methods
);
assert!(
methods.contains(&"where"),
"Builder methods should still be available after with(), got: {:?}",
methods
);
assert!(
methods.contains(&"get"),
"Builder get() should still be available after with(), got: {:?}",
methods
);
}
#[tokio::test]
async fn test_scope_available_after_model_with_then_chain() {
let brand_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Builder;
class Brand extends Model {
public function scopeIsActive(Builder $query): void {}
public function test() {
Brand::with('english')->where('active', 1)->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/Brand.php", brand_php)]);
let items = complete_at(&backend, &dir, "src/Models/Brand.php", brand_php, 7, 52).await;
let methods = method_names(&items);
assert!(
methods.contains(&"isActive"),
"After Brand::with()->where(), ->isActive() scope should be available, got: {:?}",
methods
);
}
#[tokio::test]
async fn test_completion_after_multiline_closure_argument() {
let brand_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Builder;
class Brand extends Model {
public function scopeIsActive(Builder $query): void {}
public function test() {
Brand::whereNested(function (Builder $q): void {
})
->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/Brand.php", brand_php)]);
let items = complete_at(&backend, &dir, "src/Models/Brand.php", brand_php, 9, 10).await;
let methods = method_names(&items);
assert!(
!methods.is_empty(),
"Completion after multi-line closure arg should produce results, got empty list"
);
assert!(
methods.contains(&"where"),
"Builder::where() should be available after multi-line closure, got: {:?}",
methods
);
assert!(
methods.contains(&"isActive"),
"Scope isActive() should be available after multi-line closure, got: {:?}",
methods
);
}
#[tokio::test]
async fn test_completion_after_multiline_closure_with_body() {
let brand_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Builder;
class Brand extends Model {
public function scopeIsActive(Builder $query): void {}
public function test() {
Brand::whereNested(function (Builder $q): void {
$q->where('active', true);
})
->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/Brand.php", brand_php)]);
let items = complete_at(&backend, &dir, "src/Models/Brand.php", brand_php, 10, 10).await;
let methods = method_names(&items);
assert!(
!methods.is_empty(),
"Completion after multi-line closure with body should produce results, got empty list"
);
assert!(
methods.contains(&"where"),
"Builder::where() should be available, got: {:?}",
methods
);
assert!(
methods.contains(&"isActive"),
"Scope isActive() should be available, got: {:?}",
methods
);
}
#[tokio::test]
async fn test_single_line_closure_still_works() {
let brand_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Builder;
class Brand extends Model {
public function scopeIsActive(Builder $query): void {}
public function test() {
Brand::whereNested(function (Builder $q): void {})
->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/Brand.php", brand_php)]);
let items = complete_at(&backend, &dir, "src/Models/Brand.php", brand_php, 8, 10).await;
let methods = method_names(&items);
assert!(
methods.contains(&"where"),
"Builder::where() should be available after single-line closure, got: {:?}",
methods
);
assert!(
methods.contains(&"isActive"),
"Scope isActive() should be available after single-line closure, got: {:?}",
methods
);
}
#[tokio::test]
async fn test_chunk_closure_param_inferred_via_local_collection() {
let backend = create_test_backend();
let uri = Url::parse("file:///test/chunk_local.php").unwrap();
let src = "\
<?php
class Brand {
public string $name;
public function getName(): string { return ''; }
}
/**
* @template TKey of array-key
* @template TValue
*/
class MyCollection {
/** @return int */
public function count(): int { return 0; }
/** @return TValue|null */
public function first(): mixed { return null; }
}
/**
* @template TModel
*/
class MyBuilder {
/**
* @param callable(MyCollection<int, TModel>, int): mixed $callback
* @return bool
*/
public function chunk(int $count, callable $callback): bool { return true; }
/** @return static */
public function where(string $col, mixed $val = null): static { return $this; }
}
class Service {
/** @return MyBuilder<Brand> */
public function query(): MyBuilder { return new MyBuilder(); }
public function run(): void {
$builder = $this->query();
$builder->chunk(100, function ($orders) {
$orders->
});
}
}
";
let open_params = DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: src.to_string(),
},
};
backend.did_open(open_params).await;
let completion_params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri: uri.clone() },
position: Position {
line: 33,
character: 22,
},
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let items = match backend.completion(completion_params).await.unwrap() {
Some(CompletionResponse::Array(items)) => items,
Some(CompletionResponse::List(list)) => list.items,
None => vec![],
};
let methods = method_names(&items);
assert!(
methods.contains(&"count"),
"Expected count() from MyCollection<int, Brand>, got: {:?}",
methods
);
assert!(
methods.contains(&"first"),
"Expected first() from MyCollection<int, Brand>, got: {:?}",
methods
);
}
#[tokio::test]
async fn test_chunk_closure_param_inferred_as_collection_of_model() {
let brand_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class Brand extends Model {
public string $name;
public function test() {
Brand::where('active', true)->chunk(100, function ($orders) {
$orders->
});
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/Brand.php", brand_php)]);
let items = complete_at(&backend, &dir, "src/Models/Brand.php", brand_php, 7, 22).await;
let methods = method_names(&items);
assert!(
methods.contains(&"count"),
"Expected count() from Collection<int, Brand>, got: {:?}",
methods
);
assert!(
methods.contains(&"first"),
"Expected first() from Collection<int, Brand>, got: {:?}",
methods
);
assert!(
methods.contains(&"each"),
"Expected each() from Support\\Collection<int, Brand>, got: {:?}",
methods
);
}
#[tokio::test]
async fn test_where_has_closure_param_inferred_as_builder() {
let brand_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class Brand extends Model {
public function scopeIsActive(\\Illuminate\\Database\\Eloquent\\Builder $query): void {}
public function test() {
Brand::whereHas('orders', function ($q) {
$q->
});
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/Brand.php", brand_php)]);
let items = complete_at(&backend, &dir, "src/Models/Brand.php", brand_php, 7, 16).await;
let methods = method_names(&items);
assert!(
methods.contains(&"where"),
"Expected where() from Builder<Brand>, got: {:?}",
methods
);
assert!(
methods.contains(&"orderBy"),
"Expected orderBy() from Builder<Brand>, got: {:?}",
methods
);
}
#[tokio::test]
async fn test_with_closure_param_inferred_as_relation() {
let brand_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Builder;
class Brand extends Model {
/** @param Builder<Brand> $builder */
public function test(Builder $builder) {
$builder->with('translations', function ($query) {
$query->
});
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/Brand.php", brand_php)]);
let items = complete_at(&backend, &dir, "src/Models/Brand.php", brand_php, 8, 20).await;
let methods = method_names(&items);
assert!(
methods.contains(&"where"),
"Expected where() from Relation, got: {:?}",
methods
);
assert!(
methods.contains(&"orderBy"),
"Expected orderBy() from Relation, got: {:?}",
methods
);
}
#[tokio::test]
async fn test_chunk_explicit_type_hint_takes_precedence() {
let brand_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Collection;
class Brand extends Model {
public function test() {
Brand::where('active', true)->chunk(100, function (Collection $orders) {
$orders->
});
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/Brand.php", brand_php)]);
let items = complete_at(&backend, &dir, "src/Models/Brand.php", brand_php, 7, 22).await;
let methods = method_names(&items);
assert!(
methods.contains(&"count"),
"Explicit Collection type should resolve; expected count() in {:?}",
methods
);
}
#[tokio::test]
async fn test_chunk_chain_continues_after_closure() {
let brand_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class Brand extends Model {
public function test() {
Brand::where('active', true)->chunk(100, function ($orders) {
$orders->each(function ($brand) {
$brand->
});
});
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/Brand.php", brand_php)]);
let items = complete_at(&backend, &dir, "src/Models/Brand.php", brand_php, 7, 24).await;
let methods = method_names(&items);
assert!(
!methods.is_empty(),
"Expected completions for $brand inside nested closure, got empty list"
);
}
#[tokio::test]
async fn test_has_many_count_property_produced() {
let post_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class Post extends Model {
public function getTitle(): string { return ''; }
}
";
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\HasMany;
class User extends Model {
/** @return HasMany<\\App\\Models\\Post, $this> */
public function posts(): HasMany { return $this->hasMany(Post::class); }
public function test() {
$user = new User();
$user->
}
}
";
let (backend, dir) = make_workspace(&[
("src/Models/Post.php", post_php),
("src/Models/User.php", user_php),
]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 9, 15).await;
let props = property_names(&items);
assert!(
props.contains(&"posts_count"),
"Should include synthesized 'posts_count' property, got: {:?}",
props
);
}
#[tokio::test]
async fn test_count_property_typed_as_int() {
let post_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class Post extends Model {}
";
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\HasMany;
class User extends Model {
/** @return HasMany<\\App\\Models\\Post, $this> */
public function posts(): HasMany { return $this->hasMany(Post::class); }
public function test() {
$user = new User();
$user->posts_count->
}
}
";
let (backend, dir) = make_workspace(&[
("src/Models/Post.php", post_php),
("src/Models/User.php", user_php),
]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 9, 28).await;
let methods = method_names(&items);
assert!(
!methods.contains(&"getTitle"),
"posts_count is int, should not resolve to Post methods, got: {:?}",
methods
);
}
#[tokio::test]
async fn test_multiple_relationships_produce_count_properties() {
let post_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class Post extends Model {}
";
let comment_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class Comment extends Model {}
";
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\HasMany;
use Illuminate\\Database\\Eloquent\\Relations\\HasOne;
class User extends Model {
/** @return HasMany<\\App\\Models\\Post, $this> */
public function posts(): HasMany { return $this->hasMany(Post::class); }
/** @return HasMany<\\App\\Models\\Comment, $this> */
public function comments(): HasMany { return $this->hasMany(Comment::class); }
public function test() {
$user = new User();
$user->
}
}
";
let (backend, dir) = make_workspace(&[
("src/Models/Post.php", post_php),
("src/Models/Comment.php", comment_php),
("src/Models/User.php", user_php),
]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 12, 15).await;
let props = property_names(&items);
assert!(
props.contains(&"posts_count"),
"Should include 'posts_count', got: {:?}",
props
);
assert!(
props.contains(&"comments_count"),
"Should include 'comments_count', got: {:?}",
props
);
}
#[tokio::test]
async fn test_count_property_camel_case_relationship() {
let baker_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class Baker extends Model {
public function getName(): string { return ''; }
}
";
let bakery_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\HasOne;
class Bakery extends Model {
/** @return HasOne<\\App\\Models\\Baker, $this> */
public function headBaker(): HasOne { return $this->hasOne(Baker::class); }
public function test() {
$b = new Bakery();
$b->
}
}
";
let (backend, dir) = make_workspace(&[
("src/Models/Baker.php", baker_php),
("src/Models/Bakery.php", bakery_php),
]);
let items = complete_at(&backend, &dir, "src/Models/Bakery.php", bakery_php, 9, 13).await;
let props = property_names(&items);
assert!(
props.contains(&"head_baker_count"),
"camelCase 'headBaker' should produce 'head_baker_count', got: {:?}",
props
);
}
#[tokio::test]
async fn test_count_property_on_this_arrow() {
let post_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class Post extends Model {}
";
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\HasMany;
class User extends Model {
/** @return HasMany<\\App\\Models\\Post, $this> */
public function posts(): HasMany { return $this->hasMany(Post::class); }
public function test() {
$this->
}
}
";
let (backend, dir) = make_workspace(&[
("src/Models/Post.php", post_php),
("src/Models/User.php", user_php),
]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 8, 15).await;
let props = property_names(&items);
assert!(
props.contains(&"posts_count"),
"$this-> should include 'posts_count', got: {:?}",
props
);
}
#[tokio::test]
async fn test_count_property_body_inferred_relationship() {
let post_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class Post extends Model {}
";
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
public function posts() { return $this->hasMany(Post::class); }
public function test() {
$user = new User();
$user->
}
}
";
let (backend, dir) = make_workspace(&[
("src/Models/Post.php", post_php),
("src/Models/User.php", user_php),
]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 7, 15).await;
let props = property_names(&items);
assert!(
props.contains(&"posts_count"),
"Body-inferred relationship should produce 'posts_count', got: {:?}",
props
);
}
#[tokio::test]
async fn test_count_property_coexists_with_relationship_property() {
let post_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class Post extends Model {
public function getTitle(): string { return ''; }
}
";
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\HasMany;
class User extends Model {
/** @return HasMany<\\App\\Models\\Post, $this> */
public function posts(): HasMany { return $this->hasMany(Post::class); }
public function test() {
$user = new User();
$user->
}
}
";
let (backend, dir) = make_workspace(&[
("src/Models/Post.php", post_php),
("src/Models/User.php", user_php),
]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 9, 15).await;
let props = property_names(&items);
assert!(
props.contains(&"posts"),
"Relationship property 'posts' should still exist, got: {:?}",
props
);
assert!(
props.contains(&"posts_count"),
"Count property 'posts_count' should coexist, got: {:?}",
props
);
}
#[tokio::test]
async fn test_count_property_on_inline_new_instantiation() {
let post_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class Post extends Model {
public function getTitle(): string { return ''; }
}
";
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\HasMany;
class User extends Model {
/** @return HasMany<\\App\\Models\\Post, $this> */
public function posts(): HasMany { return $this->hasMany(Post::class); }
public function test() {
(new User())->
}
}
";
let (backend, dir) = make_workspace(&[
("src/Models/Post.php", post_php),
("src/Models/User.php", user_php),
]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 8, 22).await;
let props = property_names(&items);
assert!(
props.contains(&"posts"),
"(new User())-> should include 'posts' relationship property, got: {:?}",
props
);
assert!(
props.contains(&"posts_count"),
"(new User())-> should include 'posts_count', got: {:?}",
props
);
}
#[tokio::test]
async fn test_scope_attribute_completion_static() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Builder;
use Illuminate\\Database\\Eloquent\\Attributes\\Scope;
class User extends Model {
#[Scope]
protected function active(Builder $query): void {}
public function test() {
User::
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 9, 14).await;
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
method_names.contains(&"active"),
"#[Scope] should produce a static 'active' method, got: {:?}",
method_names
);
}
#[tokio::test]
async fn test_scope_attribute_completion_instance() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Builder;
use Illuminate\\Database\\Eloquent\\Attributes\\Scope;
class User extends Model {
#[Scope]
protected function active(Builder $query): void {}
public function test() {
$user = new User();
$user->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 10, 15).await;
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
method_names.contains(&"active"),
"#[Scope] should produce an instance 'active' method, got: {:?}",
method_names
);
}
#[tokio::test]
async fn test_scope_attribute_with_extra_params() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Builder;
use Illuminate\\Database\\Eloquent\\Attributes\\Scope;
class User extends Model {
#[Scope]
protected function ofType(Builder $query, string $type): void {}
public function test() {
User::
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 9, 14).await;
let of_type: Vec<&CompletionItem> = items
.iter()
.filter(|i| {
i.kind == Some(CompletionItemKind::METHOD)
&& i.filter_text.as_deref().unwrap_or(&i.label) == "ofType"
})
.collect();
assert!(
!of_type.is_empty(),
"#[Scope] ofType should appear in completions"
);
}
#[tokio::test]
async fn test_scope_attribute_on_builder_chain() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Builder;
use Illuminate\\Database\\Eloquent\\Attributes\\Scope;
class User extends Model {
#[Scope]
protected function active(Builder $query): void {}
public function getName(): string { return ''; }
public function test() {
User::where('id', 1)->active()->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 10, 40).await;
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
method_names.contains(&"get"),
"After #[Scope] chain, Builder methods like 'get' should be available, got: {:?}",
method_names
);
}
#[tokio::test]
async fn test_scope_attribute_on_builder_variable() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Builder;
use Illuminate\\Database\\Eloquent\\Attributes\\Scope;
class User extends Model {
#[Scope]
protected function active(Builder $query): void {}
public function test() {
$q = User::where('status', 'pending');
$q->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 10, 12).await;
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
method_names.contains(&"active"),
"#[Scope] should be available on Builder variable, got: {:?}",
method_names
);
}
#[tokio::test]
async fn test_scope_attribute_and_convention_coexist() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Builder;
use Illuminate\\Database\\Eloquent\\Attributes\\Scope;
class User extends Model {
public function scopeVerified(Builder $query): void {}
#[Scope]
protected function active(Builder $query): void {}
public function test() {
User::
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 10, 14).await;
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
method_names.contains(&"active"),
"#[Scope] 'active' should appear, got: {:?}",
method_names
);
assert!(
method_names.contains(&"verified"),
"Convention 'verified' should appear, got: {:?}",
method_names
);
}
#[tokio::test]
async fn test_scope_attribute_fqn() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Builder;
class User extends Model {
#[\\Illuminate\\Database\\Eloquent\\Attributes\\Scope]
protected function active(Builder $query): void {}
public function test() {
User::
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 8, 14).await;
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
method_names.contains(&"active"),
"FQN #[Scope] should produce 'active' method, got: {:?}",
method_names
);
}
#[tokio::test]
async fn test_scope_attribute_in_trait() {
let trait_php = "\
<?php
namespace App\\Concerns;
use Illuminate\\Database\\Eloquent\\Builder;
use Illuminate\\Database\\Eloquent\\Attributes\\Scope;
trait HasActiveScope {
#[Scope]
protected function active(Builder $query): void {
$query->where('active', true);
}
}
";
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use App\\Concerns\\HasActiveScope;
class User extends Model {
use HasActiveScope;
public function test() {
User::
}
}
";
let (backend, dir) = make_workspace(&[
("src/Concerns/HasActiveScope.php", trait_php),
("src/Models/User.php", user_php),
]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 7, 14).await;
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
method_names.contains(&"active"),
"#[Scope] from trait should produce 'active' method, got: {:?}",
method_names
);
}
#[tokio::test]
async fn test_scope_attribute_query_resolution_inside_body() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Builder;
use Illuminate\\Database\\Eloquent\\Attributes\\Scope;
class User extends Model {
public function scopeVerified(Builder $query): void {}
#[Scope]
protected function active(Builder $query): void {
$query->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 9, 16).await;
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
method_names.contains(&"where"),
"$query-> inside #[Scope] body should have Builder methods, got: {:?}",
method_names
);
assert!(
method_names.contains(&"verified"),
"$query-> inside #[Scope] body should have convention scopes, got: {:?}",
method_names
);
}
#[tokio::test]
async fn test_scope_attribute_instance_from_outside_class() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Builder;
use Illuminate\\Database\\Eloquent\\Attributes\\Scope;
class User extends Model {
#[Scope]
protected function active(Builder $query): void {}
public function getName(): string { return ''; }
}
";
let demo_php = "\
<?php
namespace App\\Demo;
use App\\Models\\User;
class Demo {
public function test(): void {
$user = new User();
$user->
}
}
";
let (backend, dir) = make_workspace(&[
("src/Models/User.php", user_php),
("src/Demo/Demo.php", demo_php),
]);
let items = complete_at(&backend, &dir, "src/Demo/Demo.php", demo_php, 6, 15).await;
let method_names: Vec<&str> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
.collect();
assert!(
method_names.contains(&"active"),
"#[Scope] should appear as public instance method from outside the class, got: {:?}",
method_names
);
assert!(
method_names.contains(&"getName"),
"regular public method should also appear, got: {:?}",
method_names
);
}
#[tokio::test]
async fn test_model_method_tags_on_builder_instance() {
let soft_deletes_php = "\
<?php
namespace App\\Concerns;
/**
* @method static \\Illuminate\\Database\\Eloquent\\Builder<static> withTrashed(bool $withTrashed = true)
* @method static \\Illuminate\\Database\\Eloquent\\Builder<static> onlyTrashed()
*/
trait SoftDeletes
{
}
";
let customer_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use App\\Concerns\\SoftDeletes;
class Customer extends Model {
use SoftDeletes;
public string $email = '';
public function getDisplayName(): string { return ''; }
public function scopeActive(\\Illuminate\\Database\\Eloquent\\Builder $query): void {}
public function test(): void {
$q = Customer::where('active', true);
$q->
}
}
";
let (backend, dir) = make_workspace(&[
("src/Concerns/SoftDeletes.php", soft_deletes_php),
("src/Models/Customer.php", customer_php),
]);
let items = complete_at(
&backend,
&dir,
"src/Models/Customer.php",
customer_php,
11,
12,
)
.await;
let methods = method_names(&items);
assert!(
methods.contains(&"withTrashed"),
"Model @method 'withTrashed' from SoftDeletes should be available on Builder<Customer>, got: {:?}",
methods
);
assert!(
methods.contains(&"onlyTrashed"),
"Model @method 'onlyTrashed' from SoftDeletes should be available on Builder<Customer>, got: {:?}",
methods
);
assert!(
methods.contains(&"where"),
"Builder::where should still be available, got: {:?}",
methods
);
assert!(
methods.contains(&"active"),
"Model scope 'active' should be available on Builder<Customer>, got: {:?}",
methods
);
}
#[tokio::test]
async fn test_model_method_tags_chain_continues_after_virtual_method() {
let soft_deletes_php = "\
<?php
namespace App\\Concerns;
/**
* @method static \\Illuminate\\Database\\Eloquent\\Builder<static> withTrashed(bool $withTrashed = true)
*/
trait SoftDeletes
{
}
";
let customer_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use App\\Concerns\\SoftDeletes;
class Customer extends Model {
use SoftDeletes;
public string $email = '';
public function getDisplayName(): string { return ''; }
public function scopeActive(\\Illuminate\\Database\\Eloquent\\Builder $query): void {}
public function test(): void {
$q = Customer::where('active', true)->withTrashed();
$q->
}
}
";
let (backend, dir) = make_workspace(&[
("src/Concerns/SoftDeletes.php", soft_deletes_php),
("src/Models/Customer.php", customer_php),
]);
let items = complete_at(
&backend,
&dir,
"src/Models/Customer.php",
customer_php,
11,
12,
)
.await;
let methods = method_names(&items);
assert!(
methods.contains(&"first"),
"Builder methods should be available after withTrashed(), got: {:?}",
methods
);
assert!(
methods.contains(&"where"),
"Builder::where should be available after withTrashed(), got: {:?}",
methods
);
}
#[tokio::test]
async fn test_model_method_tags_get_returns_collection_of_model() {
let soft_deletes_php = "\
<?php
namespace App\\Concerns;
/**
* @method static \\Illuminate\\Database\\Eloquent\\Builder<static> withTrashed(bool $withTrashed = true)
*/
trait SoftDeletes
{
}
";
let customer_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use App\\Concerns\\SoftDeletes;
class Customer extends Model {
use SoftDeletes;
public string $email = '';
public function getDisplayName(): string { return ''; }
public function test(): void {
$users = Customer::groupBy('email')->withTrashed()->get();
$users->
}
}
";
let (backend, dir) = make_workspace(&[
("src/Concerns/SoftDeletes.php", soft_deletes_php),
("src/Models/Customer.php", customer_php),
]);
let items = complete_at(
&backend,
&dir,
"src/Models/Customer.php",
customer_php,
10,
16,
)
.await;
let methods = method_names(&items);
assert!(
methods.contains(&"count"),
"Collection from get() after withTrashed() should have count(), got: {:?}",
methods
);
assert!(
methods.contains(&"first"),
"Collection from get() after withTrashed() should have first(), got: {:?}",
methods
);
assert!(
!methods.contains(&"withTrashed"),
"Collection should not have withTrashed() — that would indicate double-wrapping, got: {:?}",
methods
);
}
#[tokio::test]
async fn test_model_method_tags_first_returns_model_instance() {
let soft_deletes_php = "\
<?php
namespace App\\Concerns;
/**
* @method static \\Illuminate\\Database\\Eloquent\\Builder<static> withTrashed(bool $withTrashed = true)
*/
trait SoftDeletes
{
}
";
let customer_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use App\\Concerns\\SoftDeletes;
class Customer extends Model {
use SoftDeletes;
public string $email = '';
public function getDisplayName(): string { return ''; }
public function test(): void {
$user = Customer::where('active', true)->withTrashed()->first();
$user->
}
}
";
let (backend, dir) = make_workspace(&[
("src/Concerns/SoftDeletes.php", soft_deletes_php),
("src/Models/Customer.php", customer_php),
]);
let items = complete_at(
&backend,
&dir,
"src/Models/Customer.php",
customer_php,
10,
15,
)
.await;
let methods = method_names(&items);
assert!(
methods.contains(&"getDisplayName"),
"first() after withTrashed() should return Customer with getDisplayName(), got: {:?}",
methods
);
}
#[tokio::test]
async fn test_phpdoc_property_overrides_mixed_from_unresolvable_cast() {
let setting_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
/**
* @property \\App\\Models\\Decimal $vat
*/
class Setting extends Model {
protected $casts = [
'vat' => 'UnresolvableCast',
];
protected $fillable = [
'vat',
];
public function test() {
$s = new Setting();
$s->vat->
}
}
";
let decimal_php = "\
<?php
namespace App\\Models;
class Decimal {
public function toFloat(): float { return 0.0; }
public function format(int $decimals = 2): string { return ''; }
}
";
let (backend, dir) = make_workspace(&[
("src/Models/Setting.php", setting_php),
("src/Models/Decimal.php", decimal_php),
]);
let items = complete_at(
&backend,
&dir,
"src/Models/Setting.php",
setting_php,
15,
17,
)
.await;
let methods = method_names(&items);
assert!(
methods.contains(&"toFloat"),
"PHPDoc @property Decimal should override mixed from unresolvable cast; got: {:?}",
methods
);
assert!(
methods.contains(&"format"),
"Should include format() from Decimal, got: {:?}",
methods
);
}
#[tokio::test]
async fn test_specific_cast_type_beats_phpdoc_property() {
let setting_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
/**
* @property \\App\\Models\\Decimal $is_active
*/
class Setting extends Model {
protected $casts = [
'is_active' => 'boolean',
];
public function test() {
$s = new Setting();
$s->
}
}
";
let decimal_php = "\
<?php
namespace App\\Models;
class Decimal {
public function toFloat(): float { return 0.0; }
}
";
let (backend, dir) = make_workspace(&[
("src/Models/Setting.php", setting_php),
("src/Models/Decimal.php", decimal_php),
]);
let items = complete_at(
&backend,
&dir,
"src/Models/Setting.php",
setting_php,
12,
12,
)
.await;
let is_active = items
.iter()
.find(|i| {
i.kind == Some(CompletionItemKind::PROPERTY)
&& i.filter_text.as_deref() == Some("is_active")
})
.expect("is_active should appear as a property on Setting");
let detail = is_active.detail.as_deref().unwrap_or("");
assert!(
detail.contains("bool"),
"Cast bool should beat @property Decimal — detail should contain 'bool', got: {:?}",
detail
);
assert!(
!detail.contains("Decimal"),
"Cast bool should beat @property Decimal — detail should not contain 'Decimal', got: {:?}",
detail
);
}
#[tokio::test]
async fn test_phpdoc_property_overrides_mixed_from_fillable() {
let setting_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
/**
* @property \\App\\Models\\Decimal $vat
*/
class Setting extends Model {
protected $fillable = [
'vat',
];
public function test() {
$s = new Setting();
$s->vat->
}
}
";
let decimal_php = "\
<?php
namespace App\\Models;
class Decimal {
public function toFloat(): float { return 0.0; }
}
";
let (backend, dir) = make_workspace(&[
("src/Models/Setting.php", setting_php),
("src/Models/Decimal.php", decimal_php),
]);
let items = complete_at(
&backend,
&dir,
"src/Models/Setting.php",
setting_php,
12,
17,
)
.await;
let methods = method_names(&items);
assert!(
methods.contains(&"toFloat"),
"PHPDoc @property Decimal should override mixed from $fillable, got: {:?}",
methods
);
}
#[tokio::test]
async fn test_custom_cast_class_implements_generics_resolves_tget() {
let cast_php = "\
<?php
namespace App\\Casts;
use Illuminate\\Contracts\\Database\\Eloquent\\CastsAttributes;
use App\\Models\\Decimal;
/**
* @implements CastsAttributes<Decimal, Decimal>
*/
class DecimalCast implements CastsAttributes
{
public function get($model, string $key, mixed $value, array $attributes): mixed
{
return new Decimal();
}
}
";
let setting_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use App\\Casts\\DecimalCast;
class Setting extends Model {
protected $casts = [
'vat' => DecimalCast::class,
];
public function test() {
$s = new Setting();
$s->vat->
}
}
";
let decimal_php = "\
<?php
namespace App\\Models;
class Decimal {
public function toFloat(): float { return 0.0; }
public function format(int $decimals = 2): string { return ''; }
}
";
let (backend, dir) = make_workspace(&[
("src/Models/Decimal.php", decimal_php),
("src/Casts/DecimalCast.php", cast_php),
("src/Models/Setting.php", setting_php),
(
"vendor/illuminate/Contracts/CastsAttributes.php",
CASTS_ATTRIBUTES_PHP,
),
]);
let items = complete_at(
&backend,
&dir,
"src/Models/Setting.php",
setting_php,
10,
17,
)
.await;
let methods = method_names(&items);
assert!(
methods.contains(&"toFloat"),
"Cast @implements CastsAttributes<Decimal, Decimal> should resolve vat to Decimal; got: {:?}",
methods
);
assert!(
methods.contains(&"format"),
"Should include format() from Decimal; got: {:?}",
methods
);
}
#[tokio::test]
async fn test_custom_cast_class_with_concat_argument_resolves_tget() {
let decimal_php = "\
<?php
namespace App\\Models;
class Decimal {
public function toFloat(): float { return 0.0; }
}
";
let cast_php = "\
<?php
namespace App\\Casts;
use Illuminate\\Contracts\\Database\\Eloquent\\CastsAttributes;
use App\\Models\\Decimal;
/**
* @implements CastsAttributes<Decimal, Decimal>
*/
class DecimalCast implements CastsAttributes
{
public function get($model, string $key, mixed $value, array $attributes): mixed
{
return new Decimal();
}
}
";
let setting_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use App\\Casts\\DecimalCast;
class Setting extends Model {
protected $casts = [
'vat' => DecimalCast::class . ':8:2',
];
public function test() {
$s = new Setting();
$s->vat->
}
}
";
let (backend, dir) = make_workspace(&[
("src/Models/Decimal.php", decimal_php),
("src/Casts/DecimalCast.php", cast_php),
("src/Models/Setting.php", setting_php),
(
"vendor/illuminate/Contracts/CastsAttributes.php",
CASTS_ATTRIBUTES_PHP,
),
]);
let items = complete_at(
&backend,
&dir,
"src/Models/Setting.php",
setting_php,
10,
17,
)
.await;
let methods = method_names(&items);
assert!(
methods.contains(&"toFloat"),
"Cast DecimalCast::class.':8:2' should resolve vat to Decimal via @implements; got: {:?}",
methods
);
}
const RELATION_FULL_PHP: &str = "\
<?php
namespace Illuminate\\Database\\Eloquent\\Relations;
/**
* @template TRelatedModel of \\Illuminate\\Database\\Eloquent\\Model
* @template TDeclaringModel of \\Illuminate\\Database\\Eloquent\\Model
* @template TResult
* @mixin \\Illuminate\\Database\\Eloquent\\Builder<TRelatedModel>
*/
class Relation {
/** @return static */
public function where(string $column, mixed $operator = null, mixed $value = null): static { return $this; }
/** @return static */
public function orderBy(string $column, string $direction = 'asc'): static { return $this; }
}
";
const HAS_ONE_OR_MANY_FULL_PHP: &str = "\
<?php
namespace Illuminate\\Database\\Eloquent\\Relations;
/**
* @template TRelatedModel of \\Illuminate\\Database\\Eloquent\\Model
* @template TDeclaringModel of \\Illuminate\\Database\\Eloquent\\Model
* @template TResult
* @extends Relation<TRelatedModel, TDeclaringModel, TResult>
*/
class HasOneOrMany extends Relation {}
";
const HAS_MANY_FULL_PHP: &str = "\
<?php
namespace Illuminate\\Database\\Eloquent\\Relations;
/**
* @template TRelatedModel of \\Illuminate\\Database\\Eloquent\\Model
* @template TDeclaringModel of \\Illuminate\\Database\\Eloquent\\Model
* @extends HasOneOrMany<TRelatedModel, TDeclaringModel, \\Illuminate\\Database\\Eloquent\\Collection<int, TRelatedModel>>
*/
class HasMany extends HasOneOrMany {}
";
const HAS_ONE_FULL_PHP: &str = "\
<?php
namespace Illuminate\\Database\\Eloquent\\Relations;
/**
* @template TRelatedModel of \\Illuminate\\Database\\Eloquent\\Model
* @template TDeclaringModel of \\Illuminate\\Database\\Eloquent\\Model
* @extends HasOneOrMany<TRelatedModel, TDeclaringModel, TRelatedModel|null>
*/
class HasOne extends HasOneOrMany {}
";
const BELONGS_TO_FULL_PHP: &str = "\
<?php
namespace Illuminate\\Database\\Eloquent\\Relations;
/**
* @template TRelatedModel of \\Illuminate\\Database\\Eloquent\\Model
* @template TDeclaringModel of \\Illuminate\\Database\\Eloquent\\Model
* @extends Relation<TRelatedModel, TDeclaringModel, TRelatedModel|null>
*/
class BelongsTo extends Relation {}
";
fn make_workspace_full_relations(
app_files: &[(&str, &str)],
) -> (phpantom_lsp::Backend, tempfile::TempDir) {
let mut files: Vec<(&str, &str)> = vec![
("vendor/illuminate/Eloquent/Model.php", MODEL_PHP),
(
"vendor/illuminate/Concerns/BuildsQueries.php",
BUILDS_QUERIES_PHP,
),
("vendor/illuminate/Eloquent/Collection.php", COLLECTION_PHP),
("vendor/illuminate/Eloquent/Builder.php", BUILDER_PHP),
("vendor/illuminate/Query/Builder.php", QUERY_BUILDER_PHP),
(
"vendor/illuminate/Support/Collection.php",
SUPPORT_COLLECTION_PHP,
),
(
"vendor/illuminate/Eloquent/Relations/Relation.php",
RELATION_FULL_PHP,
),
(
"vendor/illuminate/Eloquent/Relations/HasOneOrMany.php",
HAS_ONE_OR_MANY_FULL_PHP,
),
(
"vendor/illuminate/Eloquent/Relations/HasMany.php",
HAS_MANY_FULL_PHP,
),
(
"vendor/illuminate/Eloquent/Relations/HasOne.php",
HAS_ONE_FULL_PHP,
),
(
"vendor/illuminate/Eloquent/Relations/BelongsTo.php",
BELONGS_TO_FULL_PHP,
),
(
"vendor/illuminate/Eloquent/Attributes/Scope.php",
SCOPE_ATTR_PHP,
),
];
files.extend_from_slice(app_files);
create_psr4_workspace(COMPOSER_JSON, &files)
}
#[tokio::test]
async fn test_scope_on_relationship_result_via_inherited_mixin() {
let translation_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Builder;
class ProductTranslation extends Model {
/** @param Builder<self> $query */
public function scopeLanguage(Builder $query, string $code): void {}
}
";
let product_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\HasMany;
class Product extends Model {
/** @return HasMany<ProductTranslation, $this> */
public function translations(): HasMany { return $this->hasMany(ProductTranslation::class); }
public function test() {
$this->translations()->
}
}
";
let (backend, dir) = make_workspace_full_relations(&[
("src/Models/ProductTranslation.php", translation_php),
("src/Models/Product.php", product_php),
]);
let items = complete_at(&backend, &dir, "src/Models/Product.php", product_php, 8, 32).await;
let methods = method_names(&items);
assert!(
methods.contains(&"language"),
"Scope from ProductTranslation should be available on HasMany<ProductTranslation> via inherited @mixin Builder<TRelatedModel>; got: {:?}",
methods
);
assert!(
methods.contains(&"where"),
"Builder::where() should be available via @mixin; got: {:?}",
methods
);
assert!(
methods.contains(&"get"),
"Builder::get() should be available via @mixin; got: {:?}",
methods
);
assert!(
methods.contains(&"first"),
"Builder::first() (from BuildsQueries trait) should be available via @mixin; got: {:?}",
methods
);
}
#[tokio::test]
async fn test_scope_on_relationship_chain_continues() {
let translation_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Builder;
class ProductTranslation extends Model {
/** @param Builder<self> $query */
public function scopeLanguage(Builder $query, string $code): void {}
public function getLabel(): string { return ''; }
}
";
let product_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\HasMany;
class Product extends Model {
/** @return HasMany<ProductTranslation, $this> */
public function translations(): HasMany { return $this->hasMany(ProductTranslation::class); }
public function test() {
$item = $this->translations()->language('en')->first();
$item->
}
}
";
let (backend, dir) = make_workspace_full_relations(&[
("src/Models/ProductTranslation.php", translation_php),
("src/Models/Product.php", product_php),
]);
let items = complete_at(&backend, &dir, "src/Models/Product.php", product_php, 9, 15).await;
let methods = method_names(&items);
assert!(
methods.contains(&"getLabel"),
"After ->language('en')->first(), result should be ProductTranslation with getLabel(); got: {:?}",
methods
);
}
#[tokio::test]
async fn test_scope_on_has_one_relationship_via_inherited_mixin() {
let profile_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Builder;
class Profile extends Model {
/** @param Builder<self> $query */
public function scopeVerified(Builder $query): void {}
public function getBio(): string { return ''; }
}
";
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\HasOne;
class User extends Model {
/** @return HasOne<Profile, $this> */
public function profile(): HasOne { return $this->hasOne(Profile::class); }
public function test() {
$this->profile()->
}
}
";
let (backend, dir) = make_workspace_full_relations(&[
("src/Models/Profile.php", profile_php),
("src/Models/User.php", user_php),
]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 8, 26).await;
let methods = method_names(&items);
assert!(
methods.contains(&"verified"),
"Scope from Profile should be available on HasOne<Profile> via inherited @mixin; got: {:?}",
methods
);
assert!(
methods.contains(&"where"),
"Builder::where() should be available; got: {:?}",
methods
);
}
#[tokio::test]
async fn test_scope_on_belongs_to_relationship_via_inherited_mixin() {
let category_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Builder;
class Category extends Model {
/** @param Builder<self> $query */
public function scopeActive(Builder $query): void {}
}
";
let product_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;
class Product extends Model {
/** @return BelongsTo<Category, $this> */
public function category(): BelongsTo { return $this->belongsTo(Category::class); }
public function test() {
$this->category()->
}
}
";
let (backend, dir) = make_workspace_full_relations(&[
("src/Models/Category.php", category_php),
("src/Models/Product.php", product_php),
]);
let items = complete_at(&backend, &dir, "src/Models/Product.php", product_php, 8, 27).await;
let methods = method_names(&items);
assert!(
methods.contains(&"active"),
"Scope from Category should be available on BelongsTo<Category> via inherited @mixin; got: {:?}",
methods
);
}
#[tokio::test]
async fn test_model_virtual_methods_on_relationship_via_inherited_mixin() {
let customer_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Builder;
/**
* @method static Builder<static> withTrashed()
*/
class Customer extends Model {}
";
let order_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\HasMany;
class Order extends Model {
/** @return HasMany<Customer, $this> */
public function customers(): HasMany { return $this->hasMany(Customer::class); }
public function test() {
$this->customers()->
}
}
";
let (backend, dir) = make_workspace_full_relations(&[
("src/Models/Customer.php", customer_php),
("src/Models/Order.php", order_php),
]);
let items = complete_at(&backend, &dir, "src/Models/Order.php", order_php, 8, 28).await;
let methods = method_names(&items);
assert!(
methods.contains(&"withTrashed"),
"@method virtual method from Customer should be available on HasMany<Customer> via inherited @mixin; got: {:?}",
methods
);
}
#[tokio::test]
async fn test_scope_attribute_on_relationship_via_inherited_mixin() {
let post_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Builder;
use Illuminate\\Database\\Eloquent\\Attributes\\Scope;
class Post extends Model {
#[Scope]
public function published(Builder $query): void {}
}
";
let blog_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\HasMany;
class Blog extends Model {
/** @return HasMany<Post, $this> */
public function posts(): HasMany { return $this->hasMany(Post::class); }
public function test() {
$this->posts()->
}
}
";
let (backend, dir) = make_workspace_full_relations(&[
("src/Models/Post.php", post_php),
("src/Models/Blog.php", blog_php),
]);
let items = complete_at(&backend, &dir, "src/Models/Blog.php", blog_php, 8, 24).await;
let methods = method_names(&items);
assert!(
methods.contains(&"published"),
"#[Scope] method from Post should be available on HasMany<Post> via inherited @mixin; got: {:?}",
methods
);
}
const FORWARDS_CALLS_PHP: &str = "\
<?php
namespace Illuminate\\Support\\Traits;
trait ForwardsCalls {
protected function forwardCallTo(mixed $object, string $method, array $parameters): mixed { return null; }
protected function forwardDecoratedCallTo(mixed $object, string $method, array $parameters): mixed { return null; }
}
";
const MACROABLE_PHP: &str = "\
<?php
namespace Illuminate\\Support\\Traits;
trait Macroable {
public static function hasMacro(string $name): bool { return false; }
public static function __callStatic(string $method, array $parameters): mixed { return null; }
public function __call(string $method, array $parameters): mixed { return null; }
}
";
const INTERACTS_WITH_DICTIONARY_PHP: &str = "\
<?php
namespace Illuminate\\Database\\Eloquent\\Relations\\Concerns;
trait InteractsWithDictionary {
protected function getDictionaryKey(mixed $attribute): string|int|null { return null; }
}
";
const SUPPORTS_INVERSE_RELATIONS_PHP: &str = "\
<?php
namespace Illuminate\\Database\\Eloquent\\Relations\\Concerns;
trait SupportsInverseRelations {
public function inverse(?string $relation = null): static { return $this; }
}
";
const BUILDER_CONTRACT_PHP: &str = "\
<?php
namespace Illuminate\\Contracts\\Database\\Eloquent;
/**
* @mixin \\Illuminate\\Database\\Eloquent\\Builder
*/
interface Builder {}
";
const RELATION_ABSTRACT_PHP: &str = "\
<?php
namespace Illuminate\\Database\\Eloquent\\Relations;
use Illuminate\\Contracts\\Database\\Eloquent\\Builder as BuilderContract;
use Illuminate\\Support\\Traits\\ForwardsCalls;
use Illuminate\\Support\\Traits\\Macroable;
/**
* @template TRelatedModel of \\Illuminate\\Database\\Eloquent\\Model
* @template TDeclaringModel of \\Illuminate\\Database\\Eloquent\\Model
* @template TResult
* @mixin \\Illuminate\\Database\\Eloquent\\Builder<TRelatedModel>
*/
abstract class Relation implements BuilderContract {
use ForwardsCalls, Macroable {
Macroable::__call as macroCall;
}
/** @return static */
public function where(string $column, mixed $operator = null, mixed $value = null): static { return $this; }
/** @return static */
public function orderBy(string $column, string $direction = 'asc'): static { return $this; }
public function __call(string $method, array $parameters): mixed { return null; }
}
";
const HAS_ONE_OR_MANY_ABSTRACT_PHP: &str = "\
<?php
namespace Illuminate\\Database\\Eloquent\\Relations;
use Illuminate\\Database\\Eloquent\\Relations\\Concerns\\InteractsWithDictionary;
use Illuminate\\Database\\Eloquent\\Relations\\Concerns\\SupportsInverseRelations;
/**
* @template TRelatedModel of \\Illuminate\\Database\\Eloquent\\Model
* @template TDeclaringModel of \\Illuminate\\Database\\Eloquent\\Model
* @template TResult
* @extends Relation<TRelatedModel, TDeclaringModel, TResult>
*/
abstract class HasOneOrMany extends Relation {
use InteractsWithDictionary, SupportsInverseRelations;
}
";
const HAS_MANY_ABSTRACT_PHP: &str = "\
<?php
namespace Illuminate\\Database\\Eloquent\\Relations;
/**
* @template TRelatedModel of \\Illuminate\\Database\\Eloquent\\Model
* @template TDeclaringModel of \\Illuminate\\Database\\Eloquent\\Model
* @extends HasOneOrMany<TRelatedModel, TDeclaringModel, \\Illuminate\\Database\\Eloquent\\Collection<int, TRelatedModel>>
*/
class HasMany extends HasOneOrMany {}
";
const HAS_ONE_ABSTRACT_PHP: &str = "\
<?php
namespace Illuminate\\Database\\Eloquent\\Relations;
/**
* @template TRelatedModel of \\Illuminate\\Database\\Eloquent\\Model
* @template TDeclaringModel of \\Illuminate\\Database\\Eloquent\\Model
* @extends HasOneOrMany<TRelatedModel, TDeclaringModel, TRelatedModel|null>
*/
class HasOne extends HasOneOrMany {}
";
const BELONGS_TO_ABSTRACT_PHP: &str = "\
<?php
namespace Illuminate\\Database\\Eloquent\\Relations;
/**
* @template TRelatedModel of \\Illuminate\\Database\\Eloquent\\Model
* @template TDeclaringModel of \\Illuminate\\Database\\Eloquent\\Model
* @extends Relation<TRelatedModel, TDeclaringModel, TRelatedModel|null>
*/
class BelongsTo extends Relation {}
";
fn make_workspace_abstract_relations(
app_files: &[(&str, &str)],
) -> (phpantom_lsp::Backend, tempfile::TempDir) {
let mut files: Vec<(&str, &str)> = vec![
("vendor/illuminate/Eloquent/Model.php", MODEL_PHP),
(
"vendor/illuminate/Concerns/BuildsQueries.php",
BUILDS_QUERIES_PHP,
),
("vendor/illuminate/Eloquent/Collection.php", COLLECTION_PHP),
("vendor/illuminate/Eloquent/Builder.php", BUILDER_PHP),
("vendor/illuminate/Query/Builder.php", QUERY_BUILDER_PHP),
(
"vendor/illuminate/Support/Collection.php",
SUPPORT_COLLECTION_PHP,
),
(
"vendor/illuminate/Support/Traits/ForwardsCalls.php",
FORWARDS_CALLS_PHP,
),
(
"vendor/illuminate/Support/Traits/Macroable.php",
MACROABLE_PHP,
),
(
"vendor/illuminate/Eloquent/Relations/Concerns/InteractsWithDictionary.php",
INTERACTS_WITH_DICTIONARY_PHP,
),
(
"vendor/illuminate/Eloquent/Relations/Concerns/SupportsInverseRelations.php",
SUPPORTS_INVERSE_RELATIONS_PHP,
),
(
"vendor/illuminate/Contracts/Builder.php",
BUILDER_CONTRACT_PHP,
),
(
"vendor/illuminate/Eloquent/Relations/Relation.php",
RELATION_ABSTRACT_PHP,
),
(
"vendor/illuminate/Eloquent/Relations/HasOneOrMany.php",
HAS_ONE_OR_MANY_ABSTRACT_PHP,
),
(
"vendor/illuminate/Eloquent/Relations/HasMany.php",
HAS_MANY_ABSTRACT_PHP,
),
(
"vendor/illuminate/Eloquent/Relations/HasOne.php",
HAS_ONE_ABSTRACT_PHP,
),
(
"vendor/illuminate/Eloquent/Relations/BelongsTo.php",
BELONGS_TO_ABSTRACT_PHP,
),
(
"vendor/illuminate/Eloquent/Attributes/Scope.php",
SCOPE_ATTR_PHP,
),
];
files.extend_from_slice(app_files);
create_psr4_workspace(COMPOSER_JSON, &files)
}
#[tokio::test]
async fn test_scope_on_has_many_with_abstract_relation() {
let translation_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Builder;
class ProductTranslation extends Model {
/** @param Builder<self> $query */
public function scopeLanguage(Builder $query, string $code): void {}
}
";
let product_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\HasMany;
class Product extends Model {
/** @return HasMany<ProductTranslation, $this> */
public function translations(): HasMany { return $this->hasMany(ProductTranslation::class); }
public function test() {
$this->translations()->
}
}
";
let (backend, dir) = make_workspace_abstract_relations(&[
("src/Models/ProductTranslation.php", translation_php),
("src/Models/Product.php", product_php),
]);
let items = complete_at(&backend, &dir, "src/Models/Product.php", product_php, 8, 32).await;
let methods = method_names(&items);
assert!(
methods.contains(&"language"),
"T14a: Scope from ProductTranslation should be available on HasMany<ProductTranslation> \
via inherited @mixin Builder<TRelatedModel> when Relation is abstract with traits; got: {:?}",
methods
);
assert!(
methods.contains(&"where"),
"T14a: Builder::where() should be available via @mixin; got: {:?}",
methods
);
assert!(
methods.contains(&"get"),
"T14a: Builder::get() should be available via @mixin; got: {:?}",
methods
);
assert!(
methods.contains(&"first"),
"T14a: Builder::first() (from BuildsQueries trait) should be available via @mixin; got: {:?}",
methods
);
}
#[tokio::test]
async fn test_scope_on_has_one_with_abstract_relation() {
let profile_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Builder;
class Profile extends Model {
/** @param Builder<self> $query */
public function scopeVerified(Builder $query): void {}
public function getBio(): string { return ''; }
}
";
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\HasOne;
class User extends Model {
/** @return HasOne<Profile, $this> */
public function profile(): HasOne { return $this->hasOne(Profile::class); }
public function test() {
$this->profile()->
}
}
";
let (backend, dir) = make_workspace_abstract_relations(&[
("src/Models/Profile.php", profile_php),
("src/Models/User.php", user_php),
]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 8, 26).await;
let methods = method_names(&items);
assert!(
methods.contains(&"verified"),
"T14a: Scope from Profile should be available on HasOne<Profile> \
with abstract Relation; got: {:?}",
methods
);
assert!(
methods.contains(&"where"),
"T14a: Builder::where() should be available via @mixin on HasOne; got: {:?}",
methods
);
}
#[tokio::test]
async fn test_scope_chain_with_abstract_relation() {
let translation_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Builder;
class ProductTranslation extends Model {
/** @param Builder<self> $query */
public function scopeLanguage(Builder $query, string $code): void {}
public function getLabel(): string { return ''; }
}
";
let product_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\HasMany;
class Product extends Model {
/** @return HasMany<ProductTranslation, $this> */
public function translations(): HasMany { return $this->hasMany(ProductTranslation::class); }
public function test() {
$item = $this->translations()->language('en')->first();
$item->
}
}
";
let (backend, dir) = make_workspace_abstract_relations(&[
("src/Models/ProductTranslation.php", translation_php),
("src/Models/Product.php", product_php),
]);
let items = complete_at(&backend, &dir, "src/Models/Product.php", product_php, 9, 15).await;
let methods = method_names(&items);
assert!(
methods.contains(&"getLabel"),
"T14a: After ->language('en')->first(), result should be ProductTranslation \
with getLabel() when Relation is abstract with traits; got: {:?}",
methods
);
}
#[tokio::test]
async fn test_belongs_to_with_abstract_relation() {
let category_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Builder;
class Category extends Model {
/** @param Builder<self> $query */
public function scopeActive(Builder $query): void {}
}
";
let product_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;
class Product extends Model {
/** @return BelongsTo<Category, $this> */
public function category(): BelongsTo { return $this->belongsTo(Category::class); }
public function test() {
$this->category()->
}
}
";
let (backend, dir) = make_workspace_abstract_relations(&[
("src/Models/Category.php", category_php),
("src/Models/Product.php", product_php),
]);
let items = complete_at(&backend, &dir, "src/Models/Product.php", product_php, 8, 27).await;
let methods = method_names(&items);
assert!(
methods.contains(&"active"),
"T14a: Scope from Category should be available on BelongsTo<Category> \
with abstract Relation; got: {:?}",
methods
);
}
#[tokio::test]
async fn test_scope_survives_cache_prewarm_abstract_relation() {
let translation_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Builder;
class ProductTranslation extends Model {
/** @param Builder<self> $query */
public function scopeLanguage(Builder $query, string $code): void {}
}
";
let prewarm_php = "\
<?php
namespace App\\Util;
use Illuminate\\Database\\Eloquent\\Relations\\HasMany;
class RelationHelper {
public function inspect(HasMany $rel): void {
$rel->
}
}
";
let product_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\HasMany;
class Product extends Model {
/** @return HasMany<ProductTranslation, $this> */
public function translations(): HasMany { return $this->hasMany(ProductTranslation::class); }
public function test() {
$rel = $this->translations();
$rel->
}
}
";
let (backend, dir) = make_workspace_abstract_relations(&[
("src/Models/ProductTranslation.php", translation_php),
("src/Models/Product.php", product_php),
("src/Util/RelationHelper.php", prewarm_php),
]);
let _prewarm_items = complete_at(
&backend,
&dir,
"src/Util/RelationHelper.php",
prewarm_php,
5,
14,
)
.await;
let items = complete_at(&backend, &dir, "src/Models/Product.php", product_php, 9, 14).await;
let methods = method_names(&items);
assert!(
methods.contains(&"language"),
"T14a cache regression: Scope from ProductTranslation should be available on variable \
assigned from translations() even after cache was pre-warmed with plain HasMany; got: {:?}",
methods
);
assert!(
methods.contains(&"where"),
"T14a cache regression: Builder::where() should be available; got: {:?}",
methods
);
assert!(
methods.contains(&"get"),
"T14a cache regression: Builder::get() should be available; got: {:?}",
methods
);
assert!(
methods.contains(&"first"),
"T14a cache regression: Builder::first() should be available; got: {:?}",
methods
);
}
#[tokio::test]
async fn test_scope_survives_cache_prewarm_full_relations() {
let translation_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Builder;
class ProductTranslation extends Model {
/** @param Builder<self> $query */
public function scopeLanguage(Builder $query, string $code): void {}
}
";
let prewarm_php = "\
<?php
namespace App\\Util;
use Illuminate\\Database\\Eloquent\\Relations\\HasMany;
class RelationHelper {
public function inspect(HasMany $rel): void {
$rel->
}
}
";
let product_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\HasMany;
class Product extends Model {
/** @return HasMany<ProductTranslation, $this> */
public function translations(): HasMany { return $this->hasMany(ProductTranslation::class); }
public function test() {
$rel = $this->translations();
$rel->
}
}
";
let (backend, dir) = make_workspace_full_relations(&[
("src/Models/ProductTranslation.php", translation_php),
("src/Models/Product.php", product_php),
("src/Util/RelationHelper.php", prewarm_php),
]);
let _prewarm_items = complete_at(
&backend,
&dir,
"src/Util/RelationHelper.php",
prewarm_php,
5,
14,
)
.await;
let items = complete_at(&backend, &dir, "src/Models/Product.php", product_php, 9, 14).await;
let methods = method_names(&items);
assert!(
methods.contains(&"language"),
"Cache regression: Scope from ProductTranslation should be available on variable \
assigned from translations() even after cache was pre-warmed (full relations); got: {:?}",
methods
);
assert!(
methods.contains(&"where"),
"Cache regression: Builder::where() should be available (full relations); got: {:?}",
methods
);
}
#[tokio::test]
async fn test_scope_inline_chain_survives_cache_prewarm() {
let translation_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Builder;
class ProductTranslation extends Model {
/** @param Builder<self> $query */
public function scopeLanguage(Builder $query, string $code): void {}
}
";
let prewarm_php = "\
<?php
namespace App\\Util;
use Illuminate\\Database\\Eloquent\\Relations\\HasMany;
class RelationHelper {
public function inspect(HasMany $rel): void {
$rel->
}
}
";
let product_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\HasMany;
class Product extends Model {
/** @return HasMany<ProductTranslation, $this> */
public function translations(): HasMany { return $this->hasMany(ProductTranslation::class); }
public function test() {
$this->translations()->
}
}
";
let (backend, dir) = make_workspace_abstract_relations(&[
("src/Models/ProductTranslation.php", translation_php),
("src/Models/Product.php", product_php),
("src/Util/RelationHelper.php", prewarm_php),
]);
let _prewarm_items = complete_at(
&backend,
&dir,
"src/Util/RelationHelper.php",
prewarm_php,
5,
14,
)
.await;
let items = complete_at(&backend, &dir, "src/Models/Product.php", product_php, 8, 32).await;
let methods = method_names(&items);
assert!(
methods.contains(&"language"),
"Cache regression: Scope should be available on inline chain even after cache pre-warm; got: {:?}",
methods
);
assert!(
methods.contains(&"where"),
"Cache regression: Builder::where() on inline chain; got: {:?}",
methods
);
}
#[tokio::test]
async fn test_scope_on_has_many_variable_assignment_with_abstract_relation() {
let translation_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Builder;
class ProductTranslation extends Model {
/** @param Builder<self> $query */
public function scopeLanguage(Builder $query, string $code): void {}
}
";
let product_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\HasMany;
class Product extends Model {
/** @return HasMany<ProductTranslation, $this> */
public function translations(): HasMany { return $this->hasMany(ProductTranslation::class); }
public function test() {
$rel = $this->translations();
$rel->
}
}
";
let (backend, dir) = make_workspace_abstract_relations(&[
("src/Models/ProductTranslation.php", translation_php),
("src/Models/Product.php", product_php),
]);
let items = complete_at(&backend, &dir, "src/Models/Product.php", product_php, 9, 14).await;
let methods = method_names(&items);
assert!(
methods.contains(&"language"),
"T14a: Scope from ProductTranslation should be available on variable assigned from \
translations() via inherited @mixin Builder<TRelatedModel>; got: {:?}",
methods
);
assert!(
methods.contains(&"where"),
"T14a: Builder::where() should be available on variable; got: {:?}",
methods
);
assert!(
methods.contains(&"get"),
"T14a: Builder::get() should be available on variable; got: {:?}",
methods
);
assert!(
methods.contains(&"first"),
"T14a: Builder::first() should be available on variable; got: {:?}",
methods
);
}
#[tokio::test]
async fn test_scope_on_has_many_variable_assignment_full_relations() {
let translation_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Builder;
class ProductTranslation extends Model {
/** @param Builder<self> $query */
public function scopeLanguage(Builder $query, string $code): void {}
}
";
let product_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\HasMany;
class Product extends Model {
/** @return HasMany<ProductTranslation, $this> */
public function translations(): HasMany { return $this->hasMany(ProductTranslation::class); }
public function test() {
$rel = $this->translations();
$rel->
}
}
";
let (backend, dir) = make_workspace_full_relations(&[
("src/Models/ProductTranslation.php", translation_php),
("src/Models/Product.php", product_php),
]);
let items = complete_at(&backend, &dir, "src/Models/Product.php", product_php, 9, 14).await;
let methods = method_names(&items);
assert!(
methods.contains(&"language"),
"Scope from ProductTranslation should be available on variable assigned from \
translations() via inherited @mixin Builder<TRelatedModel> (full relations); got: {:?}",
methods
);
assert!(
methods.contains(&"where"),
"Builder::where() should be available on variable (full relations); got: {:?}",
methods
);
}
#[tokio::test]
async fn test_default_model_gets_timestamp_properties() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
protected $fillable = ['name', 'email'];
public function test() {
$user = new User();
$user->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 7, 15).await;
let props = property_names(&items);
assert!(
props.contains(&"created_at"),
"Default model should have created_at timestamp property, got: {:?}",
props
);
assert!(
props.contains(&"updated_at"),
"Default model should have updated_at timestamp property, got: {:?}",
props
);
assert!(
props.contains(&"name"),
"Fillable properties should still appear, got: {:?}",
props
);
}
#[tokio::test]
async fn test_timestamps_false_no_timestamp_properties() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
public $timestamps = false;
protected $fillable = ['name'];
public function test() {
$user = new User();
$user->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 8, 15).await;
let props = property_names(&items);
assert!(
!props.contains(&"created_at"),
"timestamps=false should suppress created_at, got: {:?}",
props
);
assert!(
!props.contains(&"updated_at"),
"timestamps=false should suppress updated_at, got: {:?}",
props
);
assert!(
props.contains(&"name"),
"Fillable properties should still appear, got: {:?}",
props
);
}
#[tokio::test]
async fn test_custom_created_at_column_name() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
const CREATED_AT = 'created';
public function test() {
$user = new User();
$user->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 7, 15).await;
let props = property_names(&items);
assert!(
props.contains(&"created"),
"Custom CREATED_AT should produce 'created' property, got: {:?}",
props
);
assert!(
!props.contains(&"created_at"),
"Default created_at should not appear when CREATED_AT is overridden, got: {:?}",
props
);
assert!(
props.contains(&"updated_at"),
"updated_at should still use default, got: {:?}",
props
);
}
#[tokio::test]
async fn test_null_updated_at_disables_updated_at_property() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
const UPDATED_AT = null;
public function test() {
$user = new User();
$user->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 7, 15).await;
let props = property_names(&items);
assert!(
props.contains(&"created_at"),
"created_at should still appear, got: {:?}",
props
);
assert!(
!props.contains(&"updated_at"),
"UPDATED_AT=null should suppress updated_at, got: {:?}",
props
);
}
#[tokio::test]
async fn test_casts_datetime_takes_priority_over_timestamp_default() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
protected $casts = [
'created_at' => 'immutable_datetime',
];
public function test() {
$user = new User();
$user->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 9, 15).await;
let props = property_names(&items);
let count = props.iter().filter(|p| **p == "created_at").count();
assert_eq!(
count, 1,
"created_at should appear exactly once (from casts), got {} occurrences",
count
);
assert!(
props.contains(&"updated_at"),
"updated_at should still appear from timestamp defaults, got: {:?}",
props
);
}
#[tokio::test]
async fn test_timestamps_false_with_custom_names_still_no_properties() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
public $timestamps = false;
const CREATED_AT = 'created';
const UPDATED_AT = 'modified';
public function test() {
$user = new User();
$user->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 9, 15).await;
let props = property_names(&items);
assert!(
!props.contains(&"created"),
"timestamps=false should suppress even custom created column, got: {:?}",
props
);
assert!(
!props.contains(&"modified"),
"timestamps=false should suppress even custom updated column, got: {:?}",
props
);
assert!(
!props.contains(&"created_at"),
"timestamps=false should suppress default created_at too, got: {:?}",
props
);
}
#[tokio::test]
async fn test_dates_array_produces_carbon_virtual_properties() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
protected $dates = [
'deleted_at',
'trial_ends_at',
];
public function test() {
$user = new User();
$user->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 10, 15).await;
let props = property_names(&items);
assert!(
props.contains(&"deleted_at"),
"$dates should produce deleted_at property, got: {:?}",
props
);
assert!(
props.contains(&"trial_ends_at"),
"$dates should produce trial_ends_at property, got: {:?}",
props
);
}
#[tokio::test]
async fn test_dates_type_is_carbon() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
protected $dates = ['deleted_at'];
public function test() {
$user = new User();
$user->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 7, 15).await;
let prop = items
.iter()
.find(|i| i.kind == Some(CompletionItemKind::PROPERTY) && i.label == "deleted_at");
assert!(prop.is_some(), "should find deleted_at property");
assert!(
prop.unwrap()
.detail
.as_deref()
.unwrap_or("")
.contains("Carbon"),
"deleted_at should show Carbon in detail, got: {:?}",
prop.unwrap().detail
);
}
#[tokio::test]
async fn test_casts_take_priority_over_dates() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
protected $casts = [
'deleted_at' => 'immutable_datetime',
];
protected $dates = ['deleted_at'];
public function test() {
$user = new User();
$user->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 10, 15).await;
let matching: Vec<_> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::PROPERTY) && i.label == "deleted_at")
.collect();
assert_eq!(matching.len(), 1, "should not duplicate the property");
assert!(
matching[0]
.detail
.as_deref()
.unwrap_or("")
.contains("CarbonImmutable"),
"$casts type should win over $dates, got: {:?}",
matching[0].detail
);
}
#[tokio::test]
async fn test_dates_coexist_with_casts_for_different_columns() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
protected $casts = [
'is_admin' => 'boolean',
];
protected $dates = ['deleted_at'];
public function test() {
$user = new User();
$user->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 10, 15).await;
let props = property_names(&items);
assert!(
props.contains(&"is_admin"),
"should have $casts property, got: {:?}",
props
);
assert!(
props.contains(&"deleted_at"),
"should have $dates property, got: {:?}",
props
);
}
#[tokio::test]
async fn test_dates_take_priority_over_fillable() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
protected $dates = ['deleted_at'];
protected $fillable = ['deleted_at', 'name'];
public function test() {
$user = new User();
$user->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 8, 15).await;
let matching: Vec<_> = items
.iter()
.filter(|i| i.kind == Some(CompletionItemKind::PROPERTY) && i.label == "deleted_at")
.collect();
assert_eq!(matching.len(), 1, "should not duplicate the property");
assert!(
matching[0]
.detail
.as_deref()
.unwrap_or("")
.contains("Carbon"),
"$dates type should win over fillable mixed, got: {:?}",
matching[0].detail
);
let name = items
.iter()
.find(|i| i.kind == Some(CompletionItemKind::PROPERTY) && i.label == "name");
assert!(name.is_some(), "should still have 'name' from $fillable");
}
#[tokio::test]
async fn test_dates_on_this_arrow() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
protected $dates = ['deleted_at'];
public function test() {
$this->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 6, 15).await;
let props = property_names(&items);
assert!(
props.contains(&"deleted_at"),
"$dates should produce property on $this->, got: {:?}",
props
);
}
#[tokio::test]
async fn test_dates_cross_file_psr4() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
protected $dates = ['deleted_at', 'trial_ends_at'];
}
";
let controller_php = "\
<?php
namespace App\\Models;
class Controller {
public function index() {
$user = new User();
$user->
}
}
";
let (backend, dir) = make_workspace(&[
("src/Models/User.php", user_php),
("src/Models/Controller.php", controller_php),
]);
let items = complete_at(
&backend,
&dir,
"src/Models/Controller.php",
controller_php,
5,
15,
)
.await;
let props = property_names(&items);
assert!(
props.contains(&"deleted_at"),
"$dates should produce property cross-file, got: {:?}",
props
);
assert!(
props.contains(&"trial_ends_at"),
"$dates should produce trial_ends_at cross-file, got: {:?}",
props
);
}
#[tokio::test]
async fn test_where_property_static_on_model() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
protected $casts = [
'brand_id' => 'int',
'is_active' => 'boolean',
];
public function test() {
User::
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 9, 14).await;
let methods = method_names(&items);
assert!(
methods.contains(&"whereBrandId"),
"whereBrandId should appear as static on User::, got: {:?}",
methods
);
assert!(
methods.contains(&"whereIsActive"),
"whereIsActive should appear as static on User::, got: {:?}",
methods
);
}
#[tokio::test]
async fn test_where_property_on_builder_instance() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
protected $fillable = ['name', 'email'];
}
";
let controller_php = "\
<?php
namespace App\\Http;
use App\\Models\\User;
class Controller {
public function index() {
User::where('id', 1)->
}
}
";
let (backend, dir) = make_workspace(&[
("src/Models/User.php", user_php),
("src/Http/Controller.php", controller_php),
]);
let items = complete_at(
&backend,
&dir,
"src/Http/Controller.php",
controller_php,
5,
30,
)
.await;
let methods = method_names(&items);
assert!(
methods.contains(&"whereName"),
"whereName should appear on Builder instance, got: {:?}",
methods
);
assert!(
methods.contains(&"whereEmail"),
"whereEmail should appear on Builder instance, got: {:?}",
methods
);
}
#[tokio::test]
async fn test_where_property_from_casts() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
protected $casts = [
'lang_code' => 'string',
'subcategory_id' => 'int',
];
public function test() {
User::
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 9, 14).await;
let methods = method_names(&items);
assert!(
methods.contains(&"whereLangCode"),
"whereLangCode should appear from $casts, got: {:?}",
methods
);
assert!(
methods.contains(&"whereSubcategoryId"),
"whereSubcategoryId should appear from $casts, got: {:?}",
methods
);
}
#[tokio::test]
async fn test_where_property_from_fillable() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
protected $fillable = ['language', 'is_luxury', 'is_derma'];
public function test() {
User::
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 6, 14).await;
let methods = method_names(&items);
assert!(
methods.contains(&"whereLanguage"),
"whereLanguage should appear from $fillable, got: {:?}",
methods
);
assert!(
methods.contains(&"whereIsLuxury"),
"whereIsLuxury should appear from $fillable, got: {:?}",
methods
);
assert!(
methods.contains(&"whereIsDerma"),
"whereIsDerma should appear from $fillable, got: {:?}",
methods
);
}
#[tokio::test]
async fn test_where_property_chain_continues() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
protected $fillable = ['name', 'email'];
public function test() {
User::whereName('Alice')->
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 6, 34).await;
let methods = method_names(&items);
assert!(
methods.contains(&"whereEmail"),
"whereEmail should be available after whereName() chain, got: {:?}",
methods
);
assert!(
methods.contains(&"get"),
"get() should be available after whereName() chain, got: {:?}",
methods
);
assert!(
methods.contains(&"first"),
"first() should be available after whereName() chain, got: {:?}",
methods
);
}
#[tokio::test]
async fn test_where_property_from_timestamps() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
public function test() {
User::
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 5, 14).await;
let methods = method_names(&items);
assert!(
methods.contains(&"whereCreatedAt"),
"whereCreatedAt should appear from default timestamps, got: {:?}",
methods
);
assert!(
methods.contains(&"whereUpdatedAt"),
"whereUpdatedAt should appear from default timestamps, got: {:?}",
methods
);
}
#[tokio::test]
async fn test_where_property_not_on_non_model() {
let service_php = "\
<?php
namespace App\\Services;
class UserService {
protected $fillable = ['name'];
public function test() {
UserService::
}
}
";
let (backend, dir) = make_workspace(&[("src/Services/UserService.php", service_php)]);
let items = complete_at(
&backend,
&dir,
"src/Services/UserService.php",
service_php,
4,
22,
)
.await;
let methods = method_names(&items);
assert!(
!methods.contains(&"whereName"),
"whereName should NOT appear on non-model class, got: {:?}",
methods
);
}
#[tokio::test]
async fn test_where_property_cross_file() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
protected $casts = [
'brand_id' => 'int',
];
protected $fillable = ['email'];
}
";
let controller_php = "\
<?php
namespace App\\Http;
use App\\Models\\User;
class Controller {
public function index() {
User::
}
}
";
let (backend, dir) = make_workspace(&[
("src/Models/User.php", user_php),
("src/Http/Controller.php", controller_php),
]);
let items = complete_at(
&backend,
&dir,
"src/Http/Controller.php",
controller_php,
5,
14,
)
.await;
let methods = method_names(&items);
assert!(
methods.contains(&"whereBrandId"),
"whereBrandId should appear cross-file, got: {:?}",
methods
);
assert!(
methods.contains(&"whereEmail"),
"whereEmail should appear cross-file from $fillable, got: {:?}",
methods
);
}
#[tokio::test]
async fn test_where_property_coexists_with_scopes_and_builder() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
protected $fillable = ['name'];
public function scopeActive($query) {
return $query->where('active', true);
}
public function test() {
User::
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 9, 14).await;
let methods = method_names(&items);
assert!(
methods.contains(&"whereName"),
"whereName should coexist with scopes, got: {:?}",
methods
);
assert!(
methods.contains(&"active"),
"scope active() should still work, got: {:?}",
methods
);
assert!(
methods.contains(&"where"),
"Builder where() should still work, got: {:?}",
methods
);
assert!(
methods.contains(&"get"),
"Builder get() should still work, got: {:?}",
methods
);
}
#[tokio::test]
async fn test_where_property_on_builder_variable() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
protected $fillable = ['name', 'email'];
}
";
let controller_php = "\
<?php
namespace App\\Http;
use App\\Models\\User;
class Controller {
public function index() {
$query = User::where('id', 1);
$query->
}
}
";
let (backend, dir) = make_workspace(&[
("src/Models/User.php", user_php),
("src/Http/Controller.php", controller_php),
]);
let items = complete_at(
&backend,
&dir,
"src/Http/Controller.php",
controller_php,
6,
16,
)
.await;
let methods = method_names(&items);
assert!(
methods.contains(&"whereName"),
"whereName should appear on Builder variable, got: {:?}",
methods
);
assert!(
methods.contains(&"whereEmail"),
"whereEmail should appear on Builder variable, got: {:?}",
methods
);
}
#[tokio::test]
async fn test_where_property_from_dates() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
protected $dates = ['deleted_at'];
public function test() {
User::
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 6, 14).await;
let methods = method_names(&items);
assert!(
methods.contains(&"whereDeletedAt"),
"whereDeletedAt should appear from $dates, got: {:?}",
methods
);
}
#[tokio::test]
async fn test_where_property_from_attributes_defaults() {
let user_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class User extends Model {
protected $attributes = [
'role' => 'user',
'is_active' => true,
];
public function test() {
User::
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/User.php", user_php)]);
let items = complete_at(&backend, &dir, "src/Models/User.php", user_php, 9, 14).await;
let methods = method_names(&items);
assert!(
methods.contains(&"whereRole"),
"whereRole should appear from $attributes, got: {:?}",
methods
);
assert!(
methods.contains(&"whereIsActive"),
"whereIsActive should appear from $attributes, got: {:?}",
methods
);
}
#[tokio::test]
async fn test_where_has_closure_resolves_to_related_model() {
let order_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class Order extends Model {
public function scopeRecent(\\Illuminate\\Database\\Eloquent\\Builder $query): void {}
}
";
let brand_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\HasMany;
class Brand extends Model {
/** @return HasMany<Order, $this> */
public function orders(): HasMany { return $this->hasMany(Order::class); }
public function scopeIsActive(\\Illuminate\\Database\\Eloquent\\Builder $query): void {}
public function test() {
Brand::whereHas('orders', function ($q) {
$q->
});
}
}
";
let (backend, dir) = make_workspace(&[
("src/Models/Order.php", order_php),
("src/Models/Brand.php", brand_php),
]);
let items = complete_at(&backend, &dir, "src/Models/Brand.php", brand_php, 10, 16).await;
let methods = method_names(&items);
assert!(
methods.contains(&"where"),
"Expected where() from Builder<Order>, got: {:?}",
methods
);
assert!(
methods.contains(&"recent"),
"Expected recent() scope from Order model, got: {:?}",
methods
);
assert!(
!methods.contains(&"isActive"),
"isActive() scope from Brand should NOT appear on Builder<Order>, got: {:?}",
methods
);
}
#[tokio::test]
async fn test_where_has_dot_notation_resolves_chain() {
let article_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class Article extends Model {
public function scopePublished(\\Illuminate\\Database\\Eloquent\\Builder $query): void {}
}
";
let category_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\HasMany;
class ArticleCategory extends Model {
/** @return HasMany<Article, $this> */
public function articles(): HasMany { return $this->hasMany(Article::class); }
}
";
let translation_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;
class ArticleCategoryTranslation extends Model {
/** @return BelongsTo<ArticleCategory, $this> */
public function category(): BelongsTo { return $this->belongsTo(ArticleCategory::class); }
public function test() {
ArticleCategoryTranslation::whereHas('category.articles', function ($q) {
$q->
});
}
}
";
let (backend, dir) = make_workspace(&[
("src/Models/Article.php", article_php),
("src/Models/ArticleCategory.php", category_php),
("src/Models/ArticleCategoryTranslation.php", translation_php),
]);
let items = complete_at(
&backend,
&dir,
"src/Models/ArticleCategoryTranslation.php",
translation_php,
9,
16,
)
.await;
let methods = method_names(&items);
assert!(
methods.contains(&"where"),
"Expected where() from Builder<Article>, got: {:?}",
methods
);
assert!(
methods.contains(&"published"),
"Expected published() scope from Article model, got: {:?}",
methods
);
}
#[tokio::test]
async fn test_where_has_on_builder_instance_resolves_related() {
let order_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class Order extends Model {
public function scopePending(\\Illuminate\\Database\\Eloquent\\Builder $query): void {}
}
";
let brand_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\HasMany;
class Brand extends Model {
/** @return HasMany<Order, $this> */
public function orders(): HasMany { return $this->hasMany(Order::class); }
public function scopeIsActive(\\Illuminate\\Database\\Eloquent\\Builder $query): void {}
public function test() {
Brand::where('active', true)->whereHas('orders', function ($q) {
$q->
});
}
}
";
let (backend, dir) = make_workspace(&[
("src/Models/Order.php", order_php),
("src/Models/Brand.php", brand_php),
]);
let items = complete_at(&backend, &dir, "src/Models/Brand.php", brand_php, 10, 16).await;
let methods = method_names(&items);
assert!(
methods.contains(&"where"),
"Expected where() from Builder<Order>, got: {:?}",
methods
);
assert!(
methods.contains(&"pending"),
"Expected pending() scope from Order model, got: {:?}",
methods
);
assert!(
!methods.contains(&"isActive"),
"isActive() scope from Brand should NOT appear on Builder<Order>, got: {:?}",
methods
);
}
#[tokio::test]
async fn test_where_has_missing_relation_falls_back() {
let brand_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class Brand extends Model {
public function scopeIsActive(\\Illuminate\\Database\\Eloquent\\Builder $query): void {}
public function test() {
Brand::whereHas('nonexistent', function ($q) {
$q->
});
}
}
";
let (backend, dir) = make_workspace(&[("src/Models/Brand.php", brand_php)]);
let items = complete_at(&backend, &dir, "src/Models/Brand.php", brand_php, 7, 16).await;
let methods = method_names(&items);
assert!(
methods.contains(&"where"),
"Expected where() from Builder<Brand> fallback, got: {:?}",
methods
);
assert!(
methods.contains(&"isActive"),
"Expected isActive() scope from Brand fallback, got: {:?}",
methods
);
}
#[tokio::test]
async fn test_where_doesnt_have_resolves_to_related_model() {
let order_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class Order extends Model {
public function scopeRecent(\\Illuminate\\Database\\Eloquent\\Builder $query): void {}
}
";
let brand_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\HasMany;
class Brand extends Model {
/** @return HasMany<Order, $this> */
public function orders(): HasMany { return $this->hasMany(Order::class); }
public function test() {
Brand::whereDoesntHave('orders', function ($q) {
$q->
});
}
}
";
let (backend, dir) = make_workspace(&[
("src/Models/Order.php", order_php),
("src/Models/Brand.php", brand_php),
]);
let items = complete_at(&backend, &dir, "src/Models/Brand.php", brand_php, 9, 16).await;
let methods = method_names(&items);
assert!(
methods.contains(&"where"),
"Expected where() from Builder<Order>, got: {:?}",
methods
);
assert!(
methods.contains(&"recent"),
"Expected recent() scope from Order model, got: {:?}",
methods
);
}
#[tokio::test]
async fn test_or_where_has_resolves_to_related_model() {
let order_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class Order extends Model {
public function scopeRecent(\\Illuminate\\Database\\Eloquent\\Builder $query): void {}
}
";
let brand_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\HasMany;
class Brand extends Model {
/** @return HasMany<Order, $this> */
public function orders(): HasMany { return $this->hasMany(Order::class); }
public function test() {
Brand::orWhereHas('orders', function ($q) {
$q->
});
}
}
";
let (backend, dir) = make_workspace(&[
("src/Models/Order.php", order_php),
("src/Models/Brand.php", brand_php),
]);
let items = complete_at(&backend, &dir, "src/Models/Brand.php", brand_php, 9, 16).await;
let methods = method_names(&items);
assert!(
methods.contains(&"where"),
"Expected where() from Builder<Order>, got: {:?}",
methods
);
assert!(
methods.contains(&"recent"),
"Expected recent() scope from Order model, got: {:?}",
methods
);
}
#[tokio::test]
async fn test_with_where_has_resolves_to_related_model() {
let order_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class Order extends Model {
public function scopeRecent(\\Illuminate\\Database\\Eloquent\\Builder $query): void {}
}
";
let brand_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\HasMany;
class Brand extends Model {
/** @return HasMany<Order, $this> */
public function orders(): HasMany { return $this->hasMany(Order::class); }
public function test() {
Brand::withWhereHas('orders', function ($q) {
$q->
});
}
}
";
let (backend, dir) = make_workspace(&[
("src/Models/Order.php", order_php),
("src/Models/Brand.php", brand_php),
]);
let items = complete_at(&backend, &dir, "src/Models/Brand.php", brand_php, 9, 16).await;
let methods = method_names(&items);
assert!(
methods.contains(&"where"),
"Expected where() from Builder<Order>, got: {:?}",
methods
);
assert!(
methods.contains(&"recent"),
"Expected recent() scope from Order model, got: {:?}",
methods
);
}
#[tokio::test]
async fn test_where_has_body_inferred_relationship() {
let order_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class Order extends Model {
public function scopePaid(\\Illuminate\\Database\\Eloquent\\Builder $query): void {}
}
";
let brand_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class Brand extends Model {
public function orders() { return $this->hasMany(Order::class); }
public function test() {
Brand::whereHas('orders', function ($q) {
$q->
});
}
}
";
let (backend, dir) = make_workspace(&[
("src/Models/Order.php", order_php),
("src/Models/Brand.php", brand_php),
]);
let items = complete_at(&backend, &dir, "src/Models/Brand.php", brand_php, 7, 16).await;
let methods = method_names(&items);
assert!(
methods.contains(&"where"),
"Expected where() from Builder<Order>, got: {:?}",
methods
);
assert!(
methods.contains(&"paid"),
"Expected paid() scope from Order (body-inferred relation), got: {:?}",
methods
);
}
#[tokio::test]
async fn test_where_has_belongs_to_resolves_to_parent() {
let brand_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class Brand extends Model {
public function scopeIsActive(\\Illuminate\\Database\\Eloquent\\Builder $query): void {}
}
";
let order_php = "\
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;
class Order extends Model {
/** @return BelongsTo<Brand, $this> */
public function brand(): BelongsTo { return $this->belongsTo(Brand::class); }
public function test() {
Order::whereHas('brand', function ($q) {
$q->
});
}
}
";
let (backend, dir) = make_workspace(&[
("src/Models/Brand.php", brand_php),
("src/Models/Order.php", order_php),
]);
let items = complete_at(&backend, &dir, "src/Models/Order.php", order_php, 9, 16).await;
let methods = method_names(&items);
assert!(
methods.contains(&"where"),
"Expected where() from Builder<Brand>, got: {:?}",
methods
);
assert!(
methods.contains(&"isActive"),
"Expected isActive() scope from Brand model, got: {:?}",
methods
);
}