Skip to content

Commit 173d252

Browse files
committed
Fix false unused import diagnostic for aliased namespace used in
attributes
1 parent c3638ce commit 173d252

3 files changed

Lines changed: 187 additions & 2 deletions

File tree

docs/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7979
- **Types with `covariant` or `contravariant` variance annotations in generic args now parse correctly.** Annotations like `BelongsTo<Category, covariant $this>` no longer cause the entire type to become unresolvable.
8080
- **Diagnostics now work for vendor files open in the editor.** Projects using `--prefer-source` or monorepo setups no longer have diagnostics suppressed in vendor files.
8181
- **PHPStan diagnostics no longer hidden by unrelated native diagnostics on the same line.** Deduplication now only suppresses a full-line diagnostic when the precise diagnostic on the same line reports a related issue.
82+
- **Aliased namespace imports used in attributes no longer flagged as unused.** `use Symfony\Component\Validator\Constraints as Assert;` with `#[Assert\Uuid(...)]` no longer produces a false "Unused import" diagnostic.
8283
- **`DB::select()` return type.** `DB::select()` and related methods now return `array<int, stdClass>` instead of bare `array`, and `DB::selectOne()` returns `?stdClass`.
8384
- **Redis `Connection` method resolution.** Redis commands on `Illuminate\Redis\Connections\Connection` now resolve through the phpredis stubs.
8485
- **Array shape tracking from keyed assignments inside conditional branches.** Shape types built incrementally with variable keys inside loops with if/else branching are now preserved through foreach iteration.

src/diagnostics/unused_imports.rs

Lines changed: 88 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -251,7 +251,14 @@ fn alias_is_referenced_in_content(
251251
None => break,
252252
};
253253

254-
// Check word boundaries
254+
// Check word boundaries.
255+
//
256+
// A backslash *after* the alias is a valid boundary: `Assert\Uuid`
257+
// means the file uses `Assert` as a namespace-alias prefix, which
258+
// counts as a real usage of the `use … as Assert` import.
259+
//
260+
// A backslash *before* the alias is NOT a valid boundary:
261+
// `Foo\Assert` does not reference a top-level `Assert` alias.
255262
let before_ok = if pos == 0 {
256263
true
257264
} else {
@@ -261,7 +268,8 @@ fn alias_is_referenced_in_content(
261268
let after_ok = if pos + alias_len >= content_bytes.len() {
262269
true
263270
} else {
264-
!is_ident_char(content_bytes[pos + alias_len])
271+
let next_byte = content_bytes[pos + alias_len];
272+
next_byte == b'\\' || !is_ident_char(next_byte)
265273
};
266274

267275
if before_ok && after_ok {
@@ -484,3 +492,81 @@ fn find_group_member_range(
484492

485493
None
486494
}
495+
496+
#[cfg(test)]
497+
mod tests {
498+
use super::*;
499+
500+
/// Helper: no use-statement or declaration ranges to exclude.
501+
fn referenced(content: &str, alias: &str) -> bool {
502+
alias_is_referenced_in_content(content, alias, "", &[], &[])
503+
}
504+
505+
#[test]
506+
fn backslash_after_alias_counts_as_reference() {
507+
// `Assert\Uuid` uses the `Assert` alias as a namespace prefix.
508+
let content = r#"<?php
509+
use Symfony\Component\Validator\Constraints as Assert;
510+
511+
class Dto {
512+
public function __construct(
513+
#[Assert\Uuid(message: 'bad')]
514+
public string $id,
515+
) {}
516+
}
517+
"#;
518+
let use_ranges = compute_use_line_ranges(content);
519+
assert!(
520+
alias_is_referenced_in_content(content, "Assert", "", &use_ranges, &[]),
521+
"Assert\\Uuid should count as a usage of the Assert alias"
522+
);
523+
}
524+
525+
#[test]
526+
fn backslash_before_alias_does_not_count() {
527+
// `Foo\Assert` does NOT reference a top-level `Assert` alias.
528+
assert!(!referenced(r#"Foo\Assert"#, "Assert"));
529+
}
530+
531+
#[test]
532+
fn standalone_alias_still_detected() {
533+
assert!(referenced("new Assert();", "Assert"));
534+
}
535+
536+
#[test]
537+
fn alias_inside_longer_word_not_detected() {
538+
// `Assertion` contains `Assert` but is a different identifier.
539+
assert!(!referenced("new Assertion();", "Assert"));
540+
}
541+
542+
#[test]
543+
fn alias_with_static_access_through_namespace() {
544+
// `Assert\Uuid::V7_MONOTONIC` — the alias `Assert` is used.
545+
assert!(referenced("Assert\\Uuid::V7_MONOTONIC", "Assert"));
546+
}
547+
548+
#[test]
549+
fn alias_on_use_line_not_counted() {
550+
let content = "use Foo\\Bar as Assert;\n";
551+
let use_ranges = compute_use_line_ranges(content);
552+
assert!(
553+
!alias_is_referenced_in_content(content, "Assert", "", &use_ranges, &[]),
554+
"Alias on a use-statement line should not count as a reference"
555+
);
556+
}
557+
558+
#[test]
559+
fn alias_in_comment_not_counted() {
560+
assert!(!referenced("// Assert is great\n", "Assert"));
561+
}
562+
563+
#[test]
564+
fn alias_in_docblock_prose_not_counted() {
565+
assert!(!referenced(" * Assert something here\n", "Assert"));
566+
}
567+
568+
#[test]
569+
fn alias_in_docblock_type_tag_counted() {
570+
assert!(referenced(" * @param Assert $x\n", "Assert"));
571+
}
572+
}

tests/integration/diagnostics_deprecated.rs

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2884,3 +2884,101 @@ class Beta {
28842884
deprecated[0].message
28852885
);
28862886
}
2887+
2888+
// ═══════════════════════════════════════════════════════════════════════════
2889+
// Aliased imports used in attributes (bug #64)
2890+
// ═══════════════════════════════════════════════════════════════════════════
2891+
2892+
/// An aliased namespace import used in an attribute (`#[Assert\Uuid(...)]`)
2893+
/// must not be flagged as unused.
2894+
#[test]
2895+
fn aliased_import_used_in_attribute_not_flagged() {
2896+
let backend = create_test_backend();
2897+
let uri = "file:///test_aliased_attr.php";
2898+
let text = r#"<?php
2899+
namespace App\Dto\In;
2900+
2901+
use Symfony\Component\Validator\Constraints as Assert;
2902+
2903+
final readonly class SomeKindOfInDto
2904+
{
2905+
public function __construct(
2906+
#[Assert\Uuid(
2907+
message: 'Not valid UUID::v7',
2908+
)]
2909+
public string $id,
2910+
) {}
2911+
}
2912+
"#;
2913+
2914+
let diags = unused_import_diagnostics(&backend, uri, text);
2915+
let unnecessary: Vec<_> = diags.iter().filter(|d| has_unnecessary_tag(d)).collect();
2916+
2917+
assert!(
2918+
unnecessary.is_empty(),
2919+
"Aliased import used in attribute should NOT be flagged as unused, got: {:?}",
2920+
unnecessary
2921+
);
2922+
}
2923+
2924+
/// An aliased namespace import used in a nested static constant access
2925+
/// inside an attribute argument (`Assert\Uuid::V7_MONOTONIC`) must not
2926+
/// be flagged as unused.
2927+
#[test]
2928+
fn aliased_import_used_in_attribute_static_constant_not_flagged() {
2929+
let backend = create_test_backend();
2930+
let uri = "file:///test_aliased_attr_const.php";
2931+
let text = r#"<?php
2932+
namespace App\Dto\In;
2933+
2934+
use Symfony\Component\Validator\Constraints as Assert;
2935+
2936+
final readonly class SomeKindOfInDto
2937+
{
2938+
public function __construct(
2939+
#[Assert\Uuid(
2940+
message: 'Not valid UUID::v7',
2941+
versions: [Assert\Uuid::V7_MONOTONIC],
2942+
)]
2943+
public string $id,
2944+
) {}
2945+
}
2946+
"#;
2947+
2948+
let diags = unused_import_diagnostics(&backend, uri, text);
2949+
let unnecessary: Vec<_> = diags.iter().filter(|d| has_unnecessary_tag(d)).collect();
2950+
2951+
assert!(
2952+
unnecessary.is_empty(),
2953+
"Aliased import used in attribute with static constant should NOT be flagged as unused, got: {:?}",
2954+
unnecessary
2955+
);
2956+
}
2957+
2958+
/// A non-aliased import used in an attribute should also work correctly.
2959+
#[test]
2960+
fn non_aliased_import_used_in_attribute_not_flagged() {
2961+
let backend = create_test_backend();
2962+
let uri = "file:///test_non_aliased_attr.php";
2963+
let text = r#"<?php
2964+
namespace App;
2965+
2966+
use Symfony\Component\Serializer\Attribute\SerializedName;
2967+
2968+
class Dto {
2969+
public function __construct(
2970+
#[SerializedName('product')]
2971+
public string $id,
2972+
) {}
2973+
}
2974+
"#;
2975+
2976+
let diags = unused_import_diagnostics(&backend, uri, text);
2977+
let unnecessary: Vec<_> = diags.iter().filter(|d| has_unnecessary_tag(d)).collect();
2978+
2979+
assert!(
2980+
unnecessary.is_empty(),
2981+
"Import used in attribute should NOT be flagged as unused, got: {:?}",
2982+
unnecessary
2983+
);
2984+
}

0 commit comments

Comments
 (0)