Skip to content

Commit 3a20746

Browse files
committed
Fix use of incorrect import map
1 parent b801028 commit 3a20746

2 files changed

Lines changed: 460 additions & 0 deletions

File tree

src/parser/ast_update.rs

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,15 @@ impl Backend {
217217
use_map: &HashMap<String, String>,
218218
namespace: &Option<String>,
219219
) {
220+
// Collect type alias names from ALL classes in the file up-front.
221+
// A type alias defined on one class can be referenced from methods
222+
// in a different class in the same file, so we must skip all of
223+
// them to avoid mangling alias names into FQN form.
224+
let all_alias_names: Vec<String> = classes
225+
.iter()
226+
.flat_map(|c| c.type_aliases.keys().cloned())
227+
.collect();
228+
220229
for class in classes.iter_mut() {
221230
if let Some(ref parent) = class.parent_class {
222231
let resolved = Self::resolve_name(parent, use_map, namespace);
@@ -243,6 +252,71 @@ impl Backend {
243252
Self::resolve_generics_type_args(&mut class.extends_generics, use_map, namespace);
244253
Self::resolve_generics_type_args(&mut class.implements_generics, use_map, namespace);
245254
Self::resolve_generics_type_args(&mut class.use_generics, use_map, namespace);
255+
256+
// Resolve class-like names in method return types and property
257+
// type hints so that cross-file resolution works correctly.
258+
// For example, if a method returns `Country` and the file has
259+
// `use Luxplus\Core\Enums\Country`, the return type becomes
260+
// the FQN `Luxplus\Core\Enums\Country`.
261+
//
262+
// Template params and type alias names are excluded to avoid
263+
// mangling generic types and locally-defined type aliases.
264+
// We collect alias names from ALL classes in the file because
265+
// a type alias defined on one class may be referenced from a
266+
// method in a different class in the same file.
267+
let template_params = &class.template_params;
268+
let skip_names: Vec<String> = template_params
269+
.iter()
270+
.cloned()
271+
.chain(all_alias_names.iter().cloned())
272+
.collect();
273+
274+
// Also resolve class-like names inside type alias definitions
275+
// so that `@phpstan-type ActiveUser User` where `User` is
276+
// imported via `use App\Models\User` becomes `App\Models\User`.
277+
// Skip imported aliases (`from:ClassName:OriginalName`) — those
278+
// are internal references, not type strings.
279+
for def in class.type_aliases.values_mut() {
280+
if let Some(rest) = def.strip_prefix("from:")
281+
&& let Some((class_name, original)) = rest.split_once(':')
282+
{
283+
// Imported alias — resolve the class name portion.
284+
// Format: `from:ClassName:OriginalName`
285+
let resolved_class = Self::resolve_name(class_name, use_map, namespace);
286+
*def = format!("from:{}:{}", resolved_class, original);
287+
continue;
288+
}
289+
let resolved = Self::resolve_type_string(def, use_map, namespace, &skip_names);
290+
if resolved != *def {
291+
*def = resolved;
292+
}
293+
}
294+
295+
for method in &mut class.methods {
296+
if let Some(ref ret) = method.return_type {
297+
let resolved = Self::resolve_type_string(ret, use_map, namespace, &skip_names);
298+
if resolved != *ret {
299+
method.return_type = Some(resolved);
300+
}
301+
}
302+
for param in &mut method.parameters {
303+
if let Some(ref hint) = param.type_hint {
304+
let resolved =
305+
Self::resolve_type_string(hint, use_map, namespace, &skip_names);
306+
if resolved != *hint {
307+
param.type_hint = Some(resolved);
308+
}
309+
}
310+
}
311+
}
312+
for prop in &mut class.properties {
313+
if let Some(ref hint) = prop.type_hint {
314+
let resolved = Self::resolve_type_string(hint, use_map, namespace, &skip_names);
315+
if resolved != *hint {
316+
prop.type_hint = Some(resolved);
317+
}
318+
}
319+
}
246320
}
247321
}
248322

@@ -277,6 +351,139 @@ impl Backend {
277351
}
278352
}
279353

354+
/// Resolve class-like identifiers within a type string to their
355+
/// fully-qualified forms.
356+
///
357+
/// Walks through the type string token-by-token, identifies class-like
358+
/// identifiers (words that are not scalars, keywords, or template
359+
/// params), and resolves each one via `resolve_name`.
360+
///
361+
/// Handles complex type strings including unions (`A|B`), intersections
362+
/// (`A&B`), nullable (`?A`), generics (`Collection<int, User>`), and
363+
/// array shapes (`array{name: string, user: User}`).
364+
///
365+
/// # Examples
366+
/// - `"Country"` → `"Luxplus\\Core\\Enums\\Country"` (via use map)
367+
/// - `"?Country"` → `"?Luxplus\\Core\\Enums\\Country"`
368+
/// - `"Country|null"` → `"Luxplus\\Core\\Enums\\Country|null"`
369+
/// - `"Collection<int, User>"` → `"App\\Collection<int, App\\User>"`
370+
/// - `"T"` (template param) → `"T"` (unchanged)
371+
fn resolve_type_string(
372+
type_str: &str,
373+
use_map: &HashMap<String, String>,
374+
namespace: &Option<String>,
375+
skip_names: &[String],
376+
) -> String {
377+
// Keywords that should never be resolved as class names.
378+
const TYPE_KEYWORDS: &[&str] = &[
379+
"self",
380+
"static",
381+
"parent",
382+
"$this",
383+
"mixed",
384+
"object",
385+
"void",
386+
"never",
387+
"null",
388+
"true",
389+
"false",
390+
"class-string",
391+
"list",
392+
"non-empty-list",
393+
"non-empty-array",
394+
"positive-int",
395+
"negative-int",
396+
"non-empty-string",
397+
"numeric-string",
398+
"class",
399+
"callable",
400+
"key-of",
401+
"value-of",
402+
];
403+
404+
let mut result = String::with_capacity(type_str.len());
405+
let bytes = type_str.as_bytes();
406+
let len = bytes.len();
407+
let mut i = 0;
408+
409+
// Track brace depth so we can distinguish array shape keys
410+
// (identifiers before `:` inside `{…}`) from type names.
411+
let mut brace_depth: u32 = 0;
412+
// Whether we are in "key position" inside a shape (before the `:`).
413+
// Reset to true after each `,` or `{` at the current brace level.
414+
let mut in_shape_key = false;
415+
416+
while i < len {
417+
let c = bytes[i] as char;
418+
419+
// Start of an identifier (letter, underscore, or backslash for FQN)
420+
if c.is_ascii_alphabetic() || c == '_' || c == '\\' {
421+
let start = i;
422+
// Consume the full identifier including namespace separators
423+
while i < len
424+
&& (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_' || bytes[i] == b'\\')
425+
{
426+
i += 1;
427+
}
428+
let word = &type_str[start..i];
429+
430+
// Inside `{…}` in key position, identifiers are array shape
431+
// keys (e.g. `name` in `array{name: string}`), not types.
432+
if brace_depth > 0 && in_shape_key {
433+
result.push_str(word);
434+
continue;
435+
}
436+
437+
let lower = word.to_ascii_lowercase();
438+
if is_scalar(word)
439+
|| TYPE_KEYWORDS.contains(&lower.as_str())
440+
|| skip_names.iter().any(|s| s == word)
441+
|| word.starts_with('\\')
442+
{
443+
// Leave as-is: scalar, keyword, template param,
444+
// type alias name, or already fully-qualified.
445+
result.push_str(word);
446+
} else {
447+
result.push_str(&Self::resolve_name(word, use_map, namespace));
448+
}
449+
} else if c == '$' {
450+
// Variable reference like `$this` — consume fully
451+
let start = i;
452+
i += 1;
453+
while i < len && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') {
454+
i += 1;
455+
}
456+
result.push_str(&type_str[start..i]);
457+
} else {
458+
// Track brace depth and key/value position for array shapes.
459+
match c {
460+
'{' => {
461+
brace_depth += 1;
462+
in_shape_key = true;
463+
}
464+
'}' => {
465+
brace_depth = brace_depth.saturating_sub(1);
466+
in_shape_key = brace_depth > 0;
467+
}
468+
':' if brace_depth > 0 => {
469+
// Colon separates key from value type — switch
470+
// to value position where identifiers ARE types.
471+
in_shape_key = false;
472+
}
473+
',' if brace_depth > 0 => {
474+
// Comma separates entries — next identifier is a key.
475+
in_shape_key = true;
476+
}
477+
_ => {}
478+
}
479+
result.push(c);
480+
i += 1;
481+
}
482+
}
483+
484+
result
485+
}
486+
280487
/// Resolve a class name to its fully-qualified form given a use_map and
281488
/// namespace context.
282489
fn resolve_name(

0 commit comments

Comments
 (0)