Skip to content

Commit fd58e07

Browse files
committed
Rename no longer rewrites unrelated code
1 parent b7d6c21 commit fd58e07

9 files changed

Lines changed: 521 additions & 44 deletions

File tree

docs/CHANGELOG.md

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

5555
- **Parameter name inlay hints no longer shift to the wrong parameter when only part of a multi-line call is visible.** Editors request inlay hints only for the currently visible viewport, and when a call's arguments were split across the viewport boundary, every hint after the first excluded argument was labelled with the previous parameter's name instead of its own. This was most noticeable on multi-line constructor calls with several arguments, such as those using constructor property promotion.
5656
- **Linked editing no longer types into unrelated parts of the file.** Adding a line above a variable (for example inserting a `/** */` docblock) and then typing could insert the typed characters at two arbitrary places further down, such as in the middle of a method name and in front of a trailing comment. PHPantom is re-reading the file in the background while you type, and linked editing was handing the editor positions measured against the previous version of the file, which the editor then edited on trust. Linked editing now verifies that the positions it reports still point at the variable, and offers nothing at all if they do not, so a keystroke can never land somewhere unexpected. It also becomes available in Blade templates, where the reported positions are checked against the template itself rather than the generated PHP.
57+
- **Rename no longer rewrites unrelated code when a file has just been edited.** PHPantom re-reads a file in the background after every keystroke, and rename could hand the editor positions measured against the previous version of that file, so confirming the rename edited whatever now sat at those positions. Rename and the rename preview now check that every position they report still points at the symbol, in each file the rename touches, and offer nothing at all when one does not. Retrying a moment later, once the background re-read has caught up, renames normally.
5758
- **Diagnostic and request workers no longer copy the embedded stub indexes.** Every diagnostic pass and every hover, completion, or go-to-definition request cloned the full embedded stub class, function, and constant indexes (thousands of entries each) instead of sharing them. During workspace analysis this happened twice per file, and the resulting allocation churn serialised the parallel diagnostic workers in the memory allocator. The indexes are now shared, roughly halving `analyze` wall time on large Laravel projects and removing the same overhead from every editor request.
5859
- **Member resolution no longer degrades on deeply nested class dependencies.** Class resolution previously cut off at internal nesting limits, silently returning incomplete member sets (missing completions, spurious unknown-member diagnostics) on projects with deeply intertwined hierarchies and virtual members. Those limits are gone: only genuine dependency cycles, such as two Eloquent models whose relationships reference each other, fall back to a partial view, and they now do so deterministically instead of depending on which class happened to resolve first on a given thread.
5960
- **Completion works after closing a multi-line closure argument on the same line as the next chain operator.** Typing `->` immediately after a call like `->map(function (...) { ... })->` now resolves the full receiver instead of returning no member suggestions until the operator is moved to a new line. Contributed by @calebdw.

docs/todo.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ unlikely to move the needle for most users.
113113
| D5 | [External tool diagnostic suppression actions](todo/diagnostics.md#d5-external-tool-diagnostic-suppression-actions) | Low | Low |
114114
| D15 | [Unused parameter diagnostic](todo/diagnostics.md#d15-unused-parameter-diagnostic) | Low | Low |
115115
| | **[Bug Fixes](todo/bugs.md)** | | |
116-
| B1 | [Rename can build edits from a symbol map that predates the buffer](todo/bugs.md#b1-rename-can-build-edits-from-a-symbol-map-that-predates-the-buffer) | Medium-High | Low-Medium |
116+
| B2 | [Namespace rename writes an FQN into every name of a group `use`](todo/bugs.md#b2-namespace-rename-writes-an-fqn-into-every-name-of-a-group-use) | Medium | Low |
117117
| | **[Code Actions](todo/actions.md)** | | |
118118
| A40 | [Generate method from call](todo/actions.md#a40-generate-method-from-call) | Medium-High | Medium |
119119
| A41 | [Create class from non-existing name](todo/actions.md#a41-create-class-from-non-existing-name) | Medium | Medium |

docs/todo/bugs.md

Lines changed: 38 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -7,37 +7,41 @@ pipeline so it produces correct data. Downstream consumers
77
(diagnostics, hover, completion, definition) should never need
88
to second-guess upstream output.
99

10-
## B1 Rename can build edits from a symbol map that predates the buffer
11-
12-
`symbol_maps` is refreshed on a background task after each `didChange`,
13-
and is left untouched entirely when a parse panics. Until that lands, a
14-
request reads a map whose byte offsets index the *previous* version of
15-
the file. Converting those offsets against the newer buffer yields
16-
ranges over unrelated code.
17-
18-
Linked editing hit this in practice (inserting a line above a variable
19-
and typing mirrored the keystrokes into the middle of a method name
20-
further down the file). It was fixed by checking
21-
`SymbolMap::matches_source` and verifying that every range it reports
22-
still spells the variable, dropping the whole response otherwise.
23-
24-
`rename` and `prepareRename` read the same map and turn the same offsets
25-
into `TextEdit`s, so the same corruption is reachable there:
26-
27-
- `rename/prepare.rs` builds the prepare-rename range straight from
28-
`span.start`/`span.end` via `offset_to_position` against `content`.
29-
- `rename/namespace.rs` and `rename/class.rs` read `symbol_maps` per
30-
file and emit edits from the span offsets they find.
31-
32-
It is much harder to trigger than linked editing was, because rename is
33-
an explicit user action taken after a pause rather than something the
34-
editor fires on every cursor move, and because the rename box shows the
35-
range so a wrong one is visible before the user confirms. It is the same
36-
defect class, though, and the fix is the same: gate the edit-producing
37-
paths on `matches_source` and verify each emitted range against the
38-
buffer text before returning it.
39-
40-
Note that the cross-file paths need slightly more than linked editing
41-
did: they read maps for files other than the request URI, so each one
42-
must be checked against *that* file's content, not the buffer the
43-
request arrived on.
10+
## B2 Namespace rename writes an FQN into every name of a group `use`
11+
12+
Renaming namespace `App\Old` to `App\New` turns
13+
14+
```php
15+
use App\Old\{Foo, Bar};
16+
```
17+
18+
into
19+
20+
```php
21+
use App\New\{App\New\Foo, App\New\Bar};
22+
```
23+
24+
which is a parse error. This is covered by
25+
`rename_namespace_updates_group_use` in `src/rename/tests.rs`, but the
26+
assertion only checks that `App\New` appears somewhere in the result, so
27+
it passes on the broken output.
28+
29+
Two paths in `rename/namespace.rs` edit the same line:
30+
31+
- `collect_use_statement_edits` scans the source and rewrites the group
32+
prefix (`App\Old``App\New`), which is the correct and complete edit.
33+
- `collect_fqn_reference_edits` walks the symbol map, where each name
34+
inside the braces is a `ClassReference` span whose recorded `name` is
35+
the *composed* FQN (`App\Old\Foo`) while the span itself covers only
36+
the trailing `Foo`. It therefore emits a second edit replacing `Foo`
37+
with the full `App\New\Foo`.
38+
39+
The two edits do not overlap, so the `dedup_by(ranges_overlap)` pass that
40+
resolves the ordinary `use App\Old\Foo;` case does not catch them.
41+
42+
The fix belongs in `collect_fqn_reference_edits`: a span whose source
43+
text is only a segment-aligned tail of its recorded name is a group-use
44+
member, and the group prefix edit already covers it, so it should be
45+
skipped. `source_spells_reference` in the same file already
46+
distinguishes the two shapes. Extend the test to assert the full
47+
expected line rather than a substring.

src/rename/class.rs

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -437,11 +437,24 @@ impl Backend {
437437
&& namespace_changed
438438
&& let Some(sm) = self.symbol_maps.read().get(file_uri_str).cloned()
439439
{
440-
if let Some(ns_span) = sm
441-
.spans
442-
.iter()
443-
.find(|s| matches!(&s.kind, SymbolKind::NamespaceDeclaration { .. }))
444-
{
440+
// This is the one edit in this function built straight from
441+
// symbol-map offsets rather than from a verified reference
442+
// location, so it needs the same guard: the map must
443+
// describe the file, and the span must still spell the
444+
// namespace it claims to.
445+
if !sm.matches_source(&file_content) {
446+
return None;
447+
}
448+
449+
if let Some((ns_span, ns_name)) = sm.spans.iter().find_map(|s| match &s.kind {
450+
SymbolKind::NamespaceDeclaration { name } => Some((s, name)),
451+
_ => None,
452+
}) {
453+
if file_content.get(ns_span.start as usize..ns_span.end as usize)
454+
!= Some(ns_name.as_str())
455+
{
456+
return None;
457+
}
445458
let start = offset_to_position(&file_content, ns_span.start as usize);
446459
let end = offset_to_position(&file_content, ns_span.end as usize);
447460
file_edits.push(TextEdit {

src/rename/mod.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@
2121
//! `use` import FQN is updated (last segment only), aliases are
2222
//! preserved, and collisions with existing imports are resolved by
2323
//! introducing an alias.
24+
//! - Staleness guards: every range that becomes a `TextEdit` is checked
25+
//! against the text of the file it belongs to before the response goes
26+
//! out. See [`validate`] for why.
2427
//! - Namespace rename: when renaming a namespace segment, all
2528
//! `namespace` declarations, `use` statements, and fully-qualified
2629
//! references across the workspace are updated. When a PSR-4
@@ -30,5 +33,6 @@
3033
mod class;
3134
mod namespace;
3235
mod prepare;
36+
mod validate;
3337

3438
mod tests;

src/rename/namespace.rs

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -139,13 +139,19 @@ impl Backend {
139139
self.collect_use_statement_edits(&content, old_prefix, new_prefix, &mut file_edits);
140140

141141
// 3. Update inline FQN references from the symbol map.
142-
self.collect_fqn_reference_edits(
142+
// Steps 1 and 2 scan `content` directly, so only this one
143+
// can be poisoned by a map that predates the file; when it
144+
// is, the whole rename is dropped. Emitting the other two
145+
// files' edits would leave the workspace half-renamed.
146+
if !self.collect_fqn_reference_edits(
143147
file_uri,
144148
&content,
145149
old_prefix,
146150
new_prefix,
147151
&mut file_edits,
148-
);
152+
) {
153+
return None;
154+
}
149155

150156
if !file_edits.is_empty() {
151157
// Sort edits by start position descending so they don't
@@ -371,19 +377,29 @@ impl Backend {
371377

372378
/// Collect text edits for inline FQN references (e.g. `\App\Old\Foo`
373379
/// in type hints or docblocks) that contain the old prefix.
380+
///
381+
/// Returns `false` when the file's symbol map cannot be trusted
382+
/// against its current text, in which case no edits are collected and
383+
/// the caller must abandon the rename.
374384
fn collect_fqn_reference_edits(
375385
&self,
376386
file_uri: &str,
377387
content: &str,
378388
old_prefix: &str,
379389
new_prefix: &str,
380390
edits: &mut Vec<TextEdit>,
381-
) {
391+
) -> bool {
382392
let symbol_map = match self.symbol_maps.read().get(file_uri) {
383393
Some(sm) => sm.clone(),
384-
None => return,
394+
// No map means no offsets to mistrust: this file's namespace
395+
// and use-statement edits come from scanning `content`.
396+
None => return true,
385397
};
386398

399+
if !symbol_map.matches_source(content) {
400+
return false;
401+
}
402+
387403
let old_prefix_lower = old_prefix.to_lowercase();
388404

389405
for span in &symbol_map.spans {
@@ -411,6 +427,13 @@ impl Backend {
411427
.get(span.start as usize..span.end as usize)
412428
.unwrap_or("");
413429

430+
// A same-length edit keeps `matches_source` happy while still
431+
// moving the token, so re-read the span and require it to
432+
// spell the name the map recorded.
433+
if !source_spells_reference(source, name) {
434+
return false;
435+
}
436+
414437
// Skip use-statement references (they don't have `\` in span
415438
// unless they are inline FQN like `\App\Foo` in code).
416439
// Actually, use-statement spans DO contain the full FQN.
@@ -444,6 +467,8 @@ impl Backend {
444467
new_text: new_name,
445468
});
446469
}
470+
471+
true
447472
}
448473

449474
/// Determine PSR-4 directory rename operations for a namespace rename.
@@ -517,6 +542,26 @@ impl Backend {
517542
}
518543
}
519544

545+
/// Whether `source` is text that can spell a reference recorded as `name`.
546+
///
547+
/// Normally the span covers the whole name, but a group `use`
548+
/// (`use App\Old\{Foo, Bar};`) records the composed FQN `App\Old\Foo` on a
549+
/// span that covers only the trailing `Foo`, so a segment-aligned tail
550+
/// counts too. Anything else means the offsets no longer describe the
551+
/// buffer.
552+
fn source_spells_reference(source: &str, name: &str) -> bool {
553+
let source = strip_fqn_prefix(source);
554+
let name = strip_fqn_prefix(name);
555+
source.eq_ignore_ascii_case(name)
556+
|| name
557+
.len()
558+
.checked_sub(source.len())
559+
.and_then(|split| name.split_at_checked(split))
560+
.is_some_and(|(prefix, tail)| {
561+
prefix.ends_with('\\') && tail.eq_ignore_ascii_case(source)
562+
})
563+
}
564+
520565
// ─── Namespace segment helpers ──────────────────────────────────────────────
521566

522567
/// Given a namespace name (e.g. `"App\\Bar\\Service"`) and its starting

src/rename/prepare.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ use crate::text_position::offset_to_position;
1616
use crate::util::build_fqn;
1717

1818
use super::namespace::find_namespace_segment_at_offset;
19+
use super::validate::span_spells_its_name;
1920

2021
impl Backend {
2122
/// Handle `textDocument/prepareRename`.
@@ -32,6 +33,13 @@ impl Backend {
3233
) -> Option<PrepareRenameResponse> {
3334
let span = self.lookup_symbol_at_position(uri, content, position)?;
3435

36+
// The range below is built from this span's byte offsets, and the
37+
// editor shows it as the text about to be replaced. A map that
38+
// predates the buffer would put it over unrelated code.
39+
if !self.rename_map_matches(uri, content) || !span_spells_its_name(content, &span) {
40+
return None;
41+
}
42+
3543
// Reject non-renameable symbols.
3644
if let SymbolKind::SelfStaticParent(_) = &span.kind {
3745
// self, static, parent, and $this are never renameable.
@@ -86,6 +94,14 @@ impl Backend {
8694
) -> Option<WorkspaceEdit> {
8795
let span = self.lookup_symbol_at_position(uri, content, position)?;
8896

97+
// Every edit below is derived, directly or through find-references,
98+
// from this span. If the map it came from predates the buffer the
99+
// whole response is nonsense, so drop it rather than rename the
100+
// wrong symbol.
101+
if !self.rename_map_matches(uri, content) || !span_spells_its_name(content, &span) {
102+
return None;
103+
}
104+
89105
// Reject non-renameable symbols (same logic as prepare_rename).
90106
if let SymbolKind::SelfStaticParent(_) = &span.kind {
91107
// self, static, parent, and $this are never renameable.
@@ -119,6 +135,13 @@ impl Backend {
119135
return None;
120136
}
121137

138+
// The reference finders read one symbol map per file, so each
139+
// location has to be checked against *that* file's text, not the
140+
// buffer this request arrived on.
141+
if !self.rename_locations_verified(&span.kind, &locations) {
142+
return None;
143+
}
144+
122145
// Determine whether this is a property rename. Properties are
123146
// special because the `$` prefix is part of the declaration but
124147
// usage sites via `->` or `?->` don't include it.

0 commit comments

Comments
 (0)