Skip to content

Commit e7c65cb

Browse files
committed
More Mago migration
1 parent be16bfa commit e7c65cb

8 files changed

Lines changed: 66 additions & 250 deletions

File tree

docs/CHANGELOG.md

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

6060
### Fixed
6161

62+
- **Docblock navigation lands on the right name in types that mix a `*` wildcard with a non-ASCII name.** In a type such as `@return Map<Café, *, User>`, go-to-definition, find references, and rename measured every name after the accented one against the wrong bytes, so clicking `User` resolved nothing (or the wrong symbol). The PHPStan `*` wildcard is now read directly by the type grammar rather than rewritten to `mixed` beforehand, which removes the byte-offset bookkeeping that was corrupting the positions.
6263
- **Formatting a short method chain starting with `new X(...)` no longer breaks it across lines unnecessarily.** When the constructor call's own arguments were long enough to wrap, the formatter also forced a short trailing chain like `(new Foo(...))->bar()` onto separate lines even though it would have fit on one. Upstream fix from mago 1.44.0.
6364
- **`@method` and `@property` tags on an implemented interface are now always applied.** A class that declared no docblock of its own missed the magic methods and properties its interfaces declared, so they did not complete, hover, or resolve, and calls to them were reported as unknown members. Tags on an interface (and on the interfaces it extends) are now picked up regardless of what the implementing class documents.
6465
- **Find References, Rename, and Go to Implementation no longer look stalled during startup indexing.** A search started while the background index is still parsing the workspace waits for that index to finish, since acting on a partial index would silently miss results. That wait now shows in the request's own progress bar as "Waiting for workspace index" alongside the index's live file counts, instead of sitting at "Resolving…" with no indication of what it is waiting for.

docs/todo/performance.md

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -711,20 +711,6 @@ long as the extracted data. The first is mechanical but touches every
711711
extractor in `docblock/`; the second changes the ownership model of every
712712
caller. Sized for several sprint items.
713713

714-
Two smaller pieces of the same cleanup can land independently:
715-
716-
- **The `*` wildcard pre-pass is obsolete.** `PhpType::parse` and
717-
`symbol_map/docblock.rs::emit_type_spans` both still rewrite PHPStan's
718-
`*` generic wildcard to `mixed` in the type string before parsing, and
719-
the latter maintains a byte offset map to undo the 1→5 character
720-
expansion afterwards. The grammar now models `*` natively as
721-
`Type::Wildcard`, so both passes (and the offset map) can go, replaced
722-
by mapping `Type::Wildcard` to `mixed` in `convert`.
723-
- **`get_docblock_text_for_node` re-reads raw comment text.** Tags are
724-
parsed from a `&str` recovered from trivia rather than from the trivia
725-
the main `mago-syntax` parse already produced. Feeding the parser the
726-
trivia bytes directly would drop a copy per docblock.
727-
728714
---
729715

730716
## P30. Evaluate migrating parse/resolve/docblock pipeline to `mago-hir`

src/docblock/parser.rs

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
//! so the extractors in [`super::tags`] and friends see the type and
2020
//! description exactly as the author wrote them.
2121
22-
use mago_allocator::{Arena, LocalArena};
22+
use mago_allocator::LocalArena;
2323
use mago_phpdoc_syntax::PHPDocParser;
2424
use mago_phpdoc_syntax::cst::{Document, Element};
2525
use mago_span::{HasSpan, Position, Span};
@@ -139,13 +139,11 @@ pub fn parse_docblock(docblock: &str, base_span: Span) -> Option<DocblockInfo> {
139139
return None;
140140
}
141141

142+
// `PHPDocParser::parse_with_span` ties the arena borrow and the content
143+
// borrow to one lifetime, and the caller's bytes already outlive this
144+
// scope, so they can be handed over directly — no copy into the arena.
142145
let arena = LocalArena::new();
143-
144-
// `PHPDocParser::parse_with_span` requires `content: &'arena [u8]`, so
145-
// the bytes are allocated into the arena to make the borrow live long
146-
// enough.
147-
let content: &[u8] = arena.alloc_slice_copy(docblock.as_bytes());
148-
let document = PHPDocParser::parse_with_span(&arena, content, base_span);
146+
let document = PHPDocParser::parse_with_span(&arena, docblock.as_bytes(), base_span);
149147

150148
Some(collect_tags(&document, docblock, base_span.start.offset))
151149
}

src/php_type/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ use std::hash::{Hash, Hasher};
2020
use std::ops::Deref;
2121
use std::sync::Arc;
2222

23-
use mago_allocator::{Arena, LocalArena};
23+
use mago_allocator::LocalArena;
2424
use mago_database::file::FileId;
2525
use mago_phpdoc_syntax::cst::r#type as cst;
2626
use mago_span::{Position, Span};

src/php_type/parse.rs

Lines changed: 7 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,10 @@ impl PhpType {
1717
return PhpType::untyped();
1818
}
1919

20-
// Replace PHPStan `*` wildcards in generic positions with `mixed`.
21-
let cleaned = replace_star_wildcards(input);
2220
// Replace known hyphenated pseudo-types (e.g. `model-property`)
2321
// with underscore placeholders so Mago can parse the surrounding
2422
// type structure. The placeholders are restored in the result.
25-
let cleaned = replace_hyphenated_keywords(&cleaned);
23+
let cleaned = replace_hyphenated_keywords(input);
2624
let effective: &str = &cleaned;
2725

2826
let span = Span::new(
@@ -32,8 +30,7 @@ impl PhpType {
3230
);
3331

3432
let arena = LocalArena::new();
35-
let effective = arena.alloc_slice_copy(effective.as_bytes());
36-
match mago_phpdoc_syntax::parse_type(&arena, effective, span) {
33+
match mago_phpdoc_syntax::parse_type(&arena, effective.as_bytes(), span) {
3734
Ok(ty) => restore_hyphenated_keywords(convert(&ty)),
3835
Err(_) => try_parse_hyphenated_generic(input).unwrap_or_else(|| PhpType::raw(input)),
3936
}
@@ -107,22 +104,6 @@ pub(crate) fn parse_php_float_literal(raw: &str) -> Option<f64> {
107104
clean.parse::<f64>().ok()
108105
}
109106

110-
/// Replace PHPStan `*` wildcards in generic type argument positions with
111-
/// `mixed`.
112-
///
113-
/// PHPStan's phpdoc-parser supports `*` as a bivariant wildcard inside
114-
/// generic angle brackets, e.g. `Relation<TRelatedModel, *, *>`. The
115-
/// `*` simply means "any type" and is equivalent to `mixed`.
116-
/// `mago-phpdoc-syntax` does not model it as a type, so we pre-process it.
117-
///
118-
/// Only replaces `*` tokens that appear inside angle brackets at generic
119-
/// argument boundaries: preceded (ignoring whitespace) by `<` or `,` and
120-
/// followed (ignoring whitespace) by `,` or `>`. This avoids mangling:
121-
/// - `Foo::*` — member references (preceded by `::`)
122-
/// - `int-mask-of<self::FOO_*>` — constant wildcard patterns (preceded
123-
/// by `_` or identifier chars)
124-
///
125-
/// Returns the input unchanged (no allocation) when no wildcards are found.
126107
/// Hyphenated PHPDoc pseudo-type names that `mago_phpdoc_syntax` cannot
127108
/// parse because hyphens are not valid PHP identifier characters.
128109
/// Each pair is `(hyphenated, placeholder)`.
@@ -180,77 +161,6 @@ fn try_parse_hyphenated_generic(input: &str) -> Option<PhpType> {
180161
Some(PhpType::generic(base, args))
181162
}
182163

183-
pub(crate) fn replace_star_wildcards(s: &str) -> std::borrow::Cow<'_, str> {
184-
if !s.contains('*') {
185-
return std::borrow::Cow::Borrowed(s);
186-
}
187-
188-
let bytes = s.as_bytes();
189-
190-
// First pass: check if any `*` is actually a generic wildcard.
191-
let has_generic_wildcard =
192-
(0..bytes.len()).any(|i| bytes[i] == b'*' && is_generic_wildcard(bytes, i));
193-
194-
if !has_generic_wildcard {
195-
return std::borrow::Cow::Borrowed(s);
196-
}
197-
198-
let mut result = String::with_capacity(s.len() + 16);
199-
let mut i = 0usize;
200-
201-
while i < bytes.len() {
202-
if bytes[i] == b'*' && is_generic_wildcard(bytes, i) {
203-
result.push_str("mixed");
204-
i += 1;
205-
} else {
206-
// Copy the whole UTF-8 character, not a single byte, so
207-
// multibyte characters in the type string are not mangled.
208-
let ch = s[i..].chars().next().unwrap();
209-
result.push(ch);
210-
i += ch.len_utf8();
211-
}
212-
}
213-
214-
std::borrow::Cow::Owned(result)
215-
}
216-
217-
/// Check whether the `*` at position `pos` in `bytes` is a PHPStan
218-
/// generic wildcard (as opposed to a member reference like `Foo::*`
219-
/// or a constant pattern like `self::FOO_*`).
220-
///
221-
/// A generic wildcard `*` is preceded (ignoring whitespace) by `<` or
222-
/// `,` and followed (ignoring whitespace) by `,` or `>`.
223-
pub(crate) fn is_generic_wildcard(bytes: &[u8], pos: usize) -> bool {
224-
// Check preceding non-whitespace character.
225-
let prev_ok = {
226-
let mut j = pos;
227-
loop {
228-
if j == 0 {
229-
break false;
230-
}
231-
j -= 1;
232-
if !bytes[j].is_ascii_whitespace() {
233-
break bytes[j] == b'<' || bytes[j] == b',';
234-
}
235-
}
236-
};
237-
238-
if !prev_ok {
239-
return false;
240-
}
241-
242-
// Check following non-whitespace character.
243-
let mut k = pos + 1;
244-
while k < bytes.len() {
245-
if !bytes[k].is_ascii_whitespace() {
246-
return bytes[k] == b',' || bytes[k] == b'>';
247-
}
248-
k += 1;
249-
}
250-
251-
false
252-
}
253-
254164
/// The written name of a class-like reference.
255165
///
256166
/// `mago-phpdoc-syntax` distinguishes the relative keywords (`self`,
@@ -432,6 +342,11 @@ pub(crate) fn convert(ty: &cst::Type<'_>) -> PhpType {
432342
cst::Type::LiteralFloat(l) => PhpType::literal_float(bytes_to_str(l.raw)),
433343
cst::Type::LiteralString(l) => PhpType::literal_string_raw(bytes_to_str(l.raw)),
434344

345+
// -- Wildcard: PHPStan's bivariant `*` / `_` --------------------------
346+
// `Relation<TRelatedModel, *, *>` means "any type" in that generic
347+
// position, which is exactly `mixed` for our purposes.
348+
cst::Type::Wildcard(_) => PhpType::mixed(),
349+
435350
// -- Negated / Posited literals (e.g. -42, +42) -----------------------
436351
cst::Type::Negated(n) => literal_number_type(format!("-{}", n.operand)),
437352
cst::Type::Posited(p) => literal_number_type(format!("+{}", p.operand)),

src/php_type_tests.rs

Lines changed: 16 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,7 @@ fn assert_round_trip(input: &str) {
1414
Position::new(input.len() as u32),
1515
);
1616
let arena = LocalArena::new();
17-
let input_arena = arena.alloc_slice_copy(input.as_bytes());
18-
let mago_canonical = match mago_phpdoc_syntax::parse_type(&arena, input_arena, span) {
17+
let mago_canonical = match mago_phpdoc_syntax::parse_type(&arena, input.as_bytes(), span) {
1918
Ok(ty) => ty.to_string(),
2019
Err(_) => {
2120
// If mago can't parse it, PhpType should produce Raw.
@@ -400,15 +399,10 @@ fn parse_generic_star_does_not_mangle_member_reference() {
400399
}
401400

402401
#[test]
403-
fn replace_star_wildcards_does_not_mangle_constant_pattern() {
402+
fn parse_generic_star_does_not_mangle_constant_pattern() {
404403
// `int-mask-of<self::FOO_*>` — the `*` is part of a constant
405-
// pattern, not a generic wildcard (preceded by `_`, not `<`/`,`).
406-
// Our pre-processing must leave it untouched. (mago itself may
407-
// or may not parse the result, but that's a separate issue.)
408-
use super::replace_star_wildcards;
409-
let result = replace_star_wildcards("int-mask-of<self::FOO_*>");
410-
assert_eq!(result.as_ref(), "int-mask-of<self::FOO_*>");
411-
assert!(matches!(result, std::borrow::Cow::Borrowed(_)));
404+
// pattern, not a bivariant wildcard, so it must survive parsing.
405+
assert_round_trip("int-mask-of<self::FOO_*>");
412406
}
413407

414408
#[test]
@@ -428,12 +422,10 @@ fn parse_generic_star_with_spaces() {
428422
}
429423

430424
#[test]
431-
fn replace_star_wildcards_preserves_multibyte() {
432-
// A multibyte character alongside a generic wildcard must survive
433-
// the rewrite intact (not be mangled byte-by-byte).
434-
use super::replace_star_wildcards;
435-
let result = replace_star_wildcards("Map<Café, *>");
436-
assert_eq!(result.as_ref(), "Map<Café, mixed>");
425+
fn parse_generic_star_preserves_multibyte() {
426+
// A multibyte character alongside a bivariant wildcard must survive
427+
// intact (not be mangled byte-by-byte).
428+
assert_round_trip_expected("Map<Café, *>", "Map<Café, mixed>");
437429
}
438430

439431
#[test]
@@ -454,45 +446,17 @@ fn variance_annotations_are_parsed_and_discarded() {
454446
}
455447

456448
#[test]
457-
fn replace_star_wildcards_no_star() {
458-
use super::replace_star_wildcards;
459-
let result = replace_star_wildcards("Collection<int, User>");
460-
assert_eq!(result.as_ref(), "Collection<int, User>");
461-
// Should borrow, not allocate
462-
assert!(matches!(result, std::borrow::Cow::Borrowed(_)));
463-
}
464-
465-
#[test]
466-
fn replace_star_wildcards_member_ref() {
467-
use super::replace_star_wildcards;
468-
let result = replace_star_wildcards("Foo::*");
469-
assert_eq!(result.as_ref(), "Foo::*");
470-
// Should borrow, not allocate
471-
assert!(matches!(result, std::borrow::Cow::Borrowed(_)));
472-
}
473-
474-
#[test]
475-
fn replace_star_wildcards_constant_pattern() {
476-
use super::replace_star_wildcards;
477-
let result = replace_star_wildcards("int-mask-of<self::FOO_*>");
478-
assert_eq!(result.as_ref(), "int-mask-of<self::FOO_*>");
479-
assert!(matches!(result, std::borrow::Cow::Borrowed(_)));
480-
}
481-
482-
#[test]
483-
fn replace_star_wildcards_generic() {
484-
use super::replace_star_wildcards;
485-
let result = replace_star_wildcards("Relation<TRelatedModel, *, *>");
486-
assert_eq!(result.as_ref(), "Relation<TRelatedModel, mixed, mixed>");
449+
fn parse_generic_wildcard_becomes_mixed() {
450+
assert_round_trip_expected(
451+
"Relation<TRelatedModel, *, *>",
452+
"Relation<TRelatedModel, mixed, mixed>",
453+
);
487454
}
488455

489456
#[test]
490-
fn replace_star_wildcards_single_star() {
491-
use super::replace_star_wildcards;
492-
let result = replace_star_wildcards("Voter<self::*>");
493-
// `self::*` — the `*` is preceded by `::`, not `<` or `,`
494-
assert_eq!(result.as_ref(), "Voter<self::*>");
495-
assert!(matches!(result, std::borrow::Cow::Borrowed(_)));
457+
fn parse_generic_star_keeps_member_wildcard() {
458+
// `self::*` is a member reference wildcard, not a bivariant `*`.
459+
assert_round_trip("Voter<self::*>");
496460
}
497461

498462
#[test]

0 commit comments

Comments
 (0)