Skip to content

Commit 1f102e3

Browse files
committed
Fix linked editing sometimes working of a stale cache
1 parent 535d381 commit 1f102e3

8 files changed

Lines changed: 555 additions & 88 deletions

File tree

docs/CHANGELOG.md

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

5151
### Fixed
5252

53+
- **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.
5354
- **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.
5455
- **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.
5556
- **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 & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ unlikely to move the needle for most users.
110110
| D5 | [External tool diagnostic suppression actions](todo/diagnostics.md#d5-external-tool-diagnostic-suppression-actions) | Low | Low |
111111
| D15 | [Unused parameter diagnostic](todo/diagnostics.md#d15-unused-parameter-diagnostic) | Low | Low |
112112
| | **[Bug Fixes](todo/bugs.md)** | | |
113+
| 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 |
113114
| | **[Code Actions](todo/actions.md)** | | |
114115
| A40 | [Generate method from call](todo/actions.md#a40-generate-method-from-call) | Medium-High | Medium |
115116
| 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: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,37 @@ pipeline so it produces correct data. Downstream consumers
77
(diagnostics, hover, completion, definition) should never need
88
to second-guess upstream output.
99

10-
No outstanding items.
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.

src/linked_editing.rs

Lines changed: 153 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,40 @@
3030
//! Only variables are supported. Class names, members, functions, and
3131
//! constants span multiple files and are better served by the full
3232
//! `textDocument/rename` flow.
33+
//!
34+
//! ## Why this feature validates so aggressively
35+
//!
36+
//! Every other read-only feature that mis-reads the symbol map produces a
37+
//! wrong hover or a missing completion. Linked editing produces *edits*:
38+
//! the editor takes the ranges on trust and mirrors every keystroke into
39+
//! them for as long as the mode stays active. A range that points at the
40+
//! wrong text therefore corrupts the buffer silently, and the user's undo
41+
//! history is the only way back.
42+
//!
43+
//! So the response is all-or-nothing, and three invariants are checked
44+
//! before returning anything. If any of them fails we return `None`; the
45+
//! editor simply does not offer linked editing, and re-asks on the next
46+
//! cursor move.
47+
//!
48+
//! 1. **The symbol map must match the buffer.** `symbol_maps` is refreshed
49+
//! on a background task after each keystroke and is left untouched when
50+
//! a parse panics, so the map handed to us can predate the buffer.
51+
//! Converting its byte offsets against newer text yields ranges over
52+
//! unrelated code (see [`SymbolMap::matches_source`]).
53+
//! 2. **Every range must actually spell `$name`.** Checked against the
54+
//! buffer bytes, which also guarantees the ranges are single-line and
55+
//! non-overlapping (a variable token contains no newline, and two
56+
//! equal-length `$name` tokens at different offsets cannot overlap).
57+
//! 3. **The occurrence under the cursor must be one of the ranges.**
58+
//! Linked editing only means anything if the place the user is typing is
59+
//! itself linked; if region selection ever dropped it, mirroring would
60+
//! edit occurrences the user is not looking at.
3361
3462
use tower_lsp::lsp_types::*;
3563

3664
use crate::Backend;
3765
use crate::symbol_map::{SymbolKind, SymbolMap, VarDefKind, VarDefSite};
38-
use crate::text_position::byte_range_to_lsp_range;
66+
use crate::text_position::LineIndex;
3967

4068
impl Backend {
4169
/// Compute linked editing ranges for the symbol under the cursor.
@@ -54,6 +82,11 @@ impl Backend {
5482
let maps = self.symbol_maps.read();
5583
let symbol_map = maps.get(uri)?;
5684

85+
// Invariant 1: the map must describe the text we were handed.
86+
if !symbol_map.matches_source(content) {
87+
return None;
88+
}
89+
5790
match &span.kind {
5891
SymbolKind::Variable { name } => {
5992
// Property declarations should not trigger linked editing —
@@ -69,14 +102,23 @@ impl Backend {
69102
return None;
70103
}
71104

72-
let ranges =
73-
collect_variable_ranges_in_region(symbol_map, content, name, span.start);
105+
let offsets = collect_variable_offsets_in_region(symbol_map, name, span.start);
106+
107+
// Linked editing with fewer than 2 occurrences is pointless.
108+
if offsets.len() < 2 {
109+
return None;
110+
}
74111

75-
// Linked editing with fewer than 2 ranges is pointless.
76-
if ranges.len() < 2 {
112+
// Invariant 3: the occurrence the user is typing in must be
113+
// one of the linked ranges.
114+
if !offsets.contains(&span.start) {
77115
return None;
78116
}
79117

118+
// Invariant 2: every offset must still spell `$name`.
119+
let ranges = variable_ranges_at(content, name, &offsets)?;
120+
let ranges = self.map_linked_ranges_to_blade(uri, name, ranges)?;
121+
80122
Some(LinkedEditingRanges {
81123
ranges,
82124
word_pattern: None,
@@ -85,6 +127,49 @@ impl Backend {
85127
_ => None,
86128
}
87129
}
130+
131+
/// Map ranges from virtual-PHP coordinates back to Blade source
132+
/// coordinates, or pass them through unchanged for a plain PHP file.
133+
///
134+
/// A Blade template is analysed as generated PHP, so the ranges computed
135+
/// above are positions in text the user cannot see. The source map that
136+
/// translates them back is a per-line column approximation: it clamps
137+
/// prologue lines to `0:0`, and directives that expand to more PHP than
138+
/// they occupy in the template can map several generated occurrences
139+
/// onto the same template position. Either would make the editor mirror
140+
/// keystrokes into arbitrary parts of the template.
141+
///
142+
/// So the translated ranges are re-checked against the *Blade* text with
143+
/// the same rule invariant 2 applies to PHP, and the response is dropped
144+
/// if the round trip no longer lands on `$name` at two distinct places.
145+
fn map_linked_ranges_to_blade(
146+
&self,
147+
uri: &str,
148+
var_name: &str,
149+
ranges: Vec<Range>,
150+
) -> Option<Vec<Range>> {
151+
if !self.is_blade_file(uri) {
152+
return Some(ranges);
153+
}
154+
155+
let blade = self.get_file_content(uri)?;
156+
157+
// `ranges` cover the name only, so the `$` sits one byte earlier.
158+
let mut sigil_offsets: Vec<u32> = ranges
159+
.into_iter()
160+
.map(|r| {
161+
let start = self.translate_php_to_blade(uri, r.start);
162+
crate::text_position::position_to_offset(&blade, start).checked_sub(1)
163+
})
164+
.collect::<Option<Vec<u32>>>()?;
165+
sigil_offsets.sort_unstable();
166+
sigil_offsets.dedup();
167+
if sigil_offsets.len() < 2 {
168+
return None;
169+
}
170+
171+
variable_ranges_at(&blade, var_name, &sigil_offsets)
172+
}
88173
}
89174

90175
/// A definition region identified by its owning [`VarDefSite`].
@@ -260,34 +345,61 @@ fn span_in_region(
260345
false
261346
}
262347

263-
/// Convert a byte range for a `$varName` token into an LSP [`Range`]
264-
/// that excludes the leading `$` sigil.
348+
/// Turn `$name` token offsets into LSP [`Range`]s covering just the name,
349+
/// verifying against `content` that each offset really is such a token.
350+
///
351+
/// Returns `None` if *any* offset fails to check out. Skipping the bad one
352+
/// instead would hand the editor a partial set: the dropped occurrence
353+
/// would keep its old name while the rest were renamed, which is worse
354+
/// than not offering linked editing at all.
265355
///
266-
/// The `$` is skipped by advancing `start` by one byte. This is safe
267-
/// because `$` is a single-byte ASCII character and all PHP source
268-
/// files are UTF-8 (or ASCII-compatible).
269-
fn variable_range_without_sigil(content: &str, start: usize, end: usize) -> Range {
270-
// Skip the `$` at the start of the token.
271-
let name_start = start + 1;
272-
debug_assert!(
273-
name_start <= end,
274-
"variable token too short to have a name after $"
275-
);
276-
byte_range_to_lsp_range(content, name_start, end)
356+
/// The returned ranges exclude the leading `$` sigil so that typing before
357+
/// the `$` (e.g. wrapping a variable in a function call) does not propagate
358+
/// to other occurrences. Skipping it by one byte is safe because `$` is
359+
/// single-byte ASCII and PHP source is read as UTF-8.
360+
fn variable_ranges_at(content: &str, var_name: &str, offsets: &[u32]) -> Option<Vec<Range>> {
361+
// Validate every offset before building the line table, so a stale map
362+
// costs a handful of byte comparisons rather than a pass over the file.
363+
for &offset in offsets {
364+
let start = offset as usize;
365+
if content.as_bytes().get(start) != Some(&b'$') {
366+
return None;
367+
}
368+
let name_start = start + 1;
369+
if content.get(name_start..name_start + var_name.len()) != Some(var_name) {
370+
return None;
371+
}
372+
}
373+
374+
// One line table for all the offsets: converting each one by rescanning
375+
// from the start of the file would be O(occurrences × file size) on a
376+
// request the editor makes on every cursor move.
377+
let index = LineIndex::new(content);
378+
Some(
379+
offsets
380+
.iter()
381+
.map(|&offset| {
382+
let name_start = offset as usize + 1;
383+
Range {
384+
start: index.position(name_start),
385+
end: index.position(name_start + var_name.len()),
386+
}
387+
})
388+
.collect(),
389+
)
277390
}
278391

279-
/// Collect all LSP [`Range`]s for a variable within the definition
280-
/// region that contains `cursor_offset`.
392+
/// Collect the byte offsets of every `$var` token (including its `$`)
393+
/// within the definition region that contains `cursor_offset`.
281394
///
282-
/// Ranges exclude the leading `$` sigil so that typing before the `$`
283-
/// (e.g. wrapping a variable in a function call) does not propagate to
284-
/// other occurrences.
285-
fn collect_variable_ranges_in_region(
395+
/// Offsets rather than ranges: an occurrence is always the token
396+
/// `$` + `var_name`, so the offset determines the range, and the caller
397+
/// can validate all of them against the buffer in one place.
398+
fn collect_variable_offsets_in_region(
286399
symbol_map: &SymbolMap,
287-
content: &str,
288400
var_name: &str,
289401
cursor_offset: u32,
290-
) -> Vec<Range> {
402+
) -> Vec<u32> {
291403
let scope_start = symbol_map.find_variable_scope(var_name, cursor_offset);
292404
let regions = build_regions(symbol_map, var_name, scope_start);
293405

@@ -298,8 +410,10 @@ fn collect_variable_ranges_in_region(
298410
None => return Vec::new(),
299411
};
300412

301-
let mut ranges = Vec::new();
302-
let mut seen_offsets = std::collections::HashSet::new();
413+
// Offsets may be pushed more than once (a def site that also has a
414+
// Variable span, an occurrence reached by both the region scan and the
415+
// closure bridging below); duplicates are removed in one pass at the end.
416+
let mut offsets = Vec::new();
303417

304418
// Gather variable spans within the region.
305419
for span in &symbol_map.spans {
@@ -314,14 +428,7 @@ fn collect_variable_ranges_in_region(
314428
if !span_in_region(region, span.start, symbol_map, var_name, &regions) {
315429
continue;
316430
}
317-
if !seen_offsets.insert(span.start) {
318-
continue;
319-
}
320-
ranges.push(variable_range_without_sigil(
321-
content,
322-
span.start as usize,
323-
span.end as usize,
324-
));
431+
offsets.push(span.start);
325432
}
326433
}
327434

@@ -332,14 +439,8 @@ fn collect_variable_ranges_in_region(
332439
&& def.scope_start == scope_start
333440
&& def.kind != VarDefKind::DocblockParam
334441
&& def.offset == region.def_offset
335-
&& seen_offsets.insert(def.offset)
336442
{
337-
let end_offset = def.offset + 1 + def.name.len() as u32;
338-
ranges.push(variable_range_without_sigil(
339-
content,
340-
def.offset as usize,
341-
end_offset as usize,
342-
));
443+
offsets.push(def.offset);
343444
}
344445
}
345446

@@ -377,14 +478,7 @@ fn collect_variable_ranges_in_region(
377478
if ss != closure_scope {
378479
continue;
379480
}
380-
if !seen_offsets.insert(span.start) {
381-
continue;
382-
}
383-
ranges.push(variable_range_without_sigil(
384-
content,
385-
span.start as usize,
386-
span.end as usize,
387-
));
481+
offsets.push(span.start);
388482
}
389483
}
390484
}
@@ -424,35 +518,18 @@ fn collect_variable_ranges_in_region(
424518
) {
425519
continue;
426520
}
427-
if !seen_offsets.insert(span.start) {
428-
continue;
429-
}
430-
ranges.push(variable_range_without_sigil(
431-
content,
432-
span.start as usize,
433-
span.end as usize,
434-
));
521+
offsets.push(span.start);
435522
}
436523
}
437-
// Also include the outer region's def site if not yet seen.
438-
if seen_offsets.insert(outer_region.def_offset) {
439-
let end_offset = outer_region.def_offset + 1 + var_name.len() as u32;
440-
ranges.push(variable_range_without_sigil(
441-
content,
442-
outer_region.def_offset as usize,
443-
end_offset as usize,
444-
));
445-
}
524+
// Also include the outer region's def site.
525+
offsets.push(outer_region.def_offset);
446526
}
447527
}
448528

449-
// Sort by position for a deterministic response.
450-
ranges.sort_by(|a, b| {
451-
a.start
452-
.line
453-
.cmp(&b.start.line)
454-
.then(a.start.character.cmp(&b.start.character))
455-
});
529+
// Sort for a deterministic response (offset order is document order)
530+
// and drop the duplicates noted above.
531+
offsets.sort_unstable();
532+
offsets.dedup();
456533

457-
ranges
534+
offsets
458535
}

src/server.rs

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1229,19 +1229,11 @@ impl LanguageServer for Backend {
12291229
.to_string();
12301230
let position = params.text_document_position_params.position;
12311231

1232+
// Blade coordinate translation happens inside the handler, which has
1233+
// the variable name needed to verify the translated ranges still
1234+
// point at it in the template source.
12321235
self.handle_with_position("linked_editing_range", &uri, position, |content, pos| {
12331236
self.handle_linked_editing_range(&uri, content, pos)
1234-
.map(|mut ler| {
1235-
ler.ranges = ler
1236-
.ranges
1237-
.into_iter()
1238-
.map(|r| Range {
1239-
start: self.translate_php_to_blade(&uri, r.start),
1240-
end: self.translate_php_to_blade(&uri, r.end),
1241-
})
1242-
.collect();
1243-
ler
1244-
})
12451237
})
12461238
}
12471239

0 commit comments

Comments
 (0)