Skip to content

Commit 74d8c3d

Browse files
committed
Support @mixin union types and expose all member classes
1 parent 2b9c14e commit 74d8c3d

4 files changed

Lines changed: 131 additions & 13 deletions

File tree

docs/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Fixed
1111

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

src/docblock/tags.rs

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -248,26 +248,37 @@ pub fn extract_mixin_tags_from_info(info: &DocblockInfo) -> Vec<(String, Vec<Php
248248
// Parse the type token into a structured PhpType and extract
249249
// the base class name and optional generic arguments.
250250
let parsed = PhpType::parse(type_token);
251-
let (base, generic_args) = match &parsed {
252-
PhpType::Generic(name, args) => {
253-
let cleaned_args: Vec<PhpType> = args.iter().map(strip_fqn_prefix_typed).collect();
254-
(name.clone(), cleaned_args)
255-
}
256-
PhpType::Named(name) => (name.clone(), vec![]),
257-
PhpType::Nullable(inner) => match inner.as_ref() {
258-
PhpType::Named(name) => (name.clone(), vec![]),
251+
252+
// Collect individual type members. A union like `Foo|Bar` yields
253+
// multiple mixin entries, one per member.
254+
let members: Vec<&PhpType> = match &parsed {
255+
PhpType::Union(parts) => parts.iter().collect(),
256+
other => vec![other],
257+
};
258+
259+
for member in members {
260+
let (base, generic_args) = match member {
259261
PhpType::Generic(name, args) => {
260262
let cleaned_args: Vec<PhpType> =
261263
args.iter().map(strip_fqn_prefix_typed).collect();
262264
(name.clone(), cleaned_args)
263265
}
266+
PhpType::Named(name) => (name.clone(), vec![]),
267+
PhpType::Nullable(inner) => match inner.as_ref() {
268+
PhpType::Named(name) => (name.clone(), vec![]),
269+
PhpType::Generic(name, args) => {
270+
let cleaned_args: Vec<PhpType> =
271+
args.iter().map(strip_fqn_prefix_typed).collect();
272+
(name.clone(), cleaned_args)
273+
}
274+
_ => continue,
275+
},
264276
_ => continue,
265-
},
266-
_ => continue,
267-
};
277+
};
268278

269-
if !base.is_empty() {
270-
results.push((base, generic_args));
279+
if !base.is_empty() {
280+
results.push((base, generic_args));
281+
}
271282
}
272283
}
273284

tests/integration/completion_generics.rs

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7702,6 +7702,79 @@ class KlaviyoClient {
77027702
}
77037703
}
77047704

7705+
/// When `@mixin Foo|Bar` is used, members from both classes should be available.
7706+
#[tokio::test]
7707+
async fn test_mixin_union_type() {
7708+
let backend = create_test_backend();
7709+
7710+
let uri = Url::parse("file:///mixin_union.php").unwrap();
7711+
let text = concat!(
7712+
"<?php\n",
7713+
"class Webpage {\n",
7714+
" public function getUrl(): string {}\n",
7715+
"}\n",
7716+
"class AwaitableWebpage {\n",
7717+
" public function doAwait(): void {}\n",
7718+
"}\n",
7719+
"/**\n",
7720+
" * @mixin Webpage|AwaitableWebpage\n",
7721+
" */\n",
7722+
"final class PendingAwaitablePage {\n",
7723+
" public function pending(): bool {}\n",
7724+
"}\n",
7725+
"$page = new PendingAwaitablePage();\n",
7726+
"$page->\n",
7727+
);
7728+
let open_params = DidOpenTextDocumentParams {
7729+
text_document: TextDocumentItem {
7730+
uri: uri.clone(),
7731+
language_id: "php".to_string(),
7732+
version: 1,
7733+
text: text.to_string(),
7734+
},
7735+
};
7736+
backend.did_open(open_params).await;
7737+
7738+
let line = text.lines().count() as u32 - 1;
7739+
let completion_params = CompletionParams {
7740+
text_document_position: TextDocumentPositionParams {
7741+
text_document: TextDocumentIdentifier { uri },
7742+
position: Position { line, character: 7 },
7743+
},
7744+
work_done_progress_params: WorkDoneProgressParams::default(),
7745+
partial_result_params: PartialResultParams::default(),
7746+
context: None,
7747+
};
7748+
7749+
let result = backend.completion(completion_params).await.unwrap();
7750+
assert!(result.is_some(), "Completion should return results");
7751+
7752+
match result.unwrap() {
7753+
CompletionResponse::Array(items) => {
7754+
let names: Vec<&str> = items
7755+
.iter()
7756+
.map(|i| i.filter_text.as_deref().unwrap_or(&i.label))
7757+
.collect();
7758+
assert!(
7759+
names.contains(&"getUrl"),
7760+
"Union mixin should include Webpage::getUrl(), got: {:?}",
7761+
names
7762+
);
7763+
assert!(
7764+
names.contains(&"doAwait"),
7765+
"Union mixin should include AwaitableWebpage::doAwait(), got: {:?}",
7766+
names
7767+
);
7768+
assert!(
7769+
names.contains(&"pending"),
7770+
"Own method pending() should be present, got: {:?}",
7771+
names
7772+
);
7773+
}
7774+
_ => panic!("Expected CompletionResponse::Array"),
7775+
}
7776+
}
7777+
77057778
/// When `@param Closure(T): void $cb` receives `fn(User $u) => ...`,
77067779
/// the template param `T` should be inferred from the closure's parameter
77077780
/// type (contravariant position).

tests/unit/docblock_parsing.rs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1306,6 +1306,39 @@ fn mixin_tag_empty_after_tag() {
13061306
assert!(mixins.is_empty());
13071307
}
13081308

1309+
#[test]
1310+
fn mixin_tag_union() {
1311+
let doc = concat!("/**\n", " * @mixin Webpage|AwaitableWebpage\n", " */",);
1312+
let mixins = extract_mixin_tags(doc);
1313+
assert_eq!(
1314+
mixins,
1315+
vec![
1316+
("Webpage".to_string(), vec![]),
1317+
("AwaitableWebpage".to_string(), vec![]),
1318+
]
1319+
);
1320+
}
1321+
1322+
#[test]
1323+
fn mixin_tag_union_with_generics() {
1324+
let doc = concat!(
1325+
"/**\n",
1326+
" * @mixin Builder<User>|Collection<int, User>\n",
1327+
" */",
1328+
);
1329+
let mixins = extract_mixin_tags(doc);
1330+
assert_eq!(
1331+
mixins,
1332+
vec![
1333+
("Builder".to_string(), vec![PhpType::parse("User")]),
1334+
(
1335+
"Collection".to_string(),
1336+
vec![PhpType::parse("int"), PhpType::parse("User")],
1337+
),
1338+
]
1339+
);
1340+
}
1341+
13091342
// ─── @phpstan-assert / @psalm-assert extraction ─────────────────────────
13101343

13111344
#[test]

0 commit comments

Comments
 (0)