Skip to content

Commit 0548de7

Browse files
committed
Enable incremental text sync and handle incremental didChange edits
1 parent ecd7e42 commit 0548de7

6 files changed

Lines changed: 167 additions & 29 deletions

File tree

docs/CHANGELOG.md

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

4444
### Changed
4545

46+
- **Incremental text sync.** The server now uses incremental document sync, receiving only changed ranges from the editor instead of the full file content on every keystroke.
4647
- **Replace FQCN with import.** Now replaces all occurrences of the same FQCN throughout the file in one action, not just the one under the cursor. A new "Replace all FQCNs with imports" action appears when the file contains multiple distinct FQCNs, replacing all of them at once (skipping those with import conflicts).
4748
- **LSP responsiveness.** Hover, go-to-definition, signature help, code actions, rename, and other handlers now run on background threads. Slow requests no longer block other requests or cancellations.
4849
- **Faster analysis.** Analysis time cut significantly on large projects.

docs/todo.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ within the same impact tier.
2626
| # | Item | Impact | Effort |
2727
| --- | ----------------------------------------------------------------------------------------------------------------------- | ----------- | ------ |
2828
| P13 | [Tiered storage: drop per-file maps for non-open files](todo/performance.md#p13-tiered-storage-drop-per-file-maps-for-non-open-files) | Medium-High | Medium-High |
29+
| P10 | [Redundant `parse_and_cache_file` from multiple threads](todo/performance.md#p10-redundant-parse_and_cache_file-from-multiple-threads) | Medium | Low |
2930
| D10 | [PHPMD diagnostic proxy](todo/diagnostics.md#d10-phpmd-diagnostic-proxy) | Low | Medium |
3031
| | **Release 0.8.0** | | |
3132

@@ -144,7 +145,6 @@ unlikely to move the needle for most users.
144145
| F5 | [Call hierarchy](todo/lsp-features.md#f5-call-hierarchy) (incoming/outgoing calls) | Medium | Medium |
145146
| F2 | [Partial result streaming via `$/progress`](todo/lsp-features.md#f2-partial-result-streaming-via-progress) | Medium | Medium-High |
146147
| F7 | [Evaluatable expression support (DAP integration)](todo/lsp-features.md#f7-evaluatable-expression-support-dap-integration) | Low-Medium | Low |
147-
| F3 | Incremental text sync | Low-Medium | Medium |
148148
| F8 | [Test ↔ implementation navigation via `@covers`](todo/lsp-features.md#f8-test--implementation-navigation-via-covers) | Low | Medium |
149149
| | **[Signature Help](todo/signature-help.md)** | | |
150150
| S1 | [Attribute constructor signature help](todo/signature-help.md#s1-attribute-constructor-signature-help) | Medium | Medium |
@@ -168,7 +168,6 @@ unlikely to move the needle for most users.
168168
| P13 | [Tiered storage: drop per-file maps for non-open files](todo/performance.md#p13-tiered-storage-drop-per-file-maps-for-non-open-files) | Medium-High | Medium-High |
169169
| P14 | [Eager docblock parsing into structured fields](todo/performance.md#p14-eager-docblock-parsing-into-structured-fields) | Medium | Medium |
170170
| P9 | [`resolved_class_cache` generic-arg specialisation](todo/performance.md#p9-resolved_class_cache-generic-arg-specialisation) | Medium | Medium |
171-
| P10 | [Redundant `parse_and_cache_file` from multiple threads](todo/performance.md#p10-redundant-parse_and_cache_file-from-multiple-threads) | Medium | Low |
172171
| P11 | [Uncached base-resolution in `build_scope_methods_for_builder`](todo/performance.md#p11-uncached-base-resolution-in-build_scope_methods_for_builder) | Low-Medium | Low |
173172
| P3 | Parallel pre-filter in `find_implementors` | Low-Medium | Medium |
174173
| P4 | `memmem` for block comment terminator search | Low | Low |

src/definition/member/file_lookup.rs

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -114,9 +114,7 @@ impl Backend {
114114
}
115115
// Fallback: the target file may have been closed (didClose clears
116116
// ast_map). Check class_index which survives close (issue #99).
117-
.or_else(|| {
118-
self.class_index.read().get(class_name).cloned()
119-
})?;
117+
.or_else(|| self.class_index.read().get(class_name).cloned())?;
120118

121119
// Get the file content.
122120
let file_content = if uri == current_uri {

src/server.rs

Lines changed: 44 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ impl LanguageServer for Backend {
140140
}),
141141
inlay_hint_provider: Some(OneOf::Left(true)),
142142
text_document_sync: Some(TextDocumentSyncCapability::Kind(
143-
TextDocumentSyncKind::FULL,
143+
TextDocumentSyncKind::INCREMENTAL,
144144
)),
145145
hover_provider: Some(HoverProviderCapability::Simple(true)),
146146
definition_provider: Some(OneOf::Left(true)),
@@ -469,29 +469,53 @@ impl LanguageServer for Backend {
469469
async fn did_change(&self, params: DidChangeTextDocumentParams) {
470470
let uri = params.text_document.uri.to_string();
471471

472-
if let Some(change) = params.content_changes.first() {
473-
let text = Arc::new(change.text.clone());
472+
if params.content_changes.is_empty() {
473+
return;
474+
}
475+
476+
// Apply incremental edits to the current content.
477+
// Each change event either has a range (incremental) or replaces
478+
// the entire document (range is None).
479+
let text = {
480+
let open_files = self.open_files.read();
481+
let mut current = open_files
482+
.get(&uri)
483+
.map(|s| s.to_string())
484+
.unwrap_or_default();
485+
drop(open_files);
486+
487+
for change in &params.content_changes {
488+
if let Some(range) = change.range {
489+
let start = crate::util::position_to_byte_offset(&current, range.start);
490+
let end = crate::util::position_to_byte_offset(&current, range.end);
491+
current.replace_range(start..end, &change.text);
492+
} else {
493+
// Full content replacement (fallback)
494+
current = change.text.clone();
495+
}
496+
}
497+
Arc::new(current)
498+
};
474499

475-
// Update stored content
476-
self.open_files
477-
.write()
478-
.insert(uri.clone(), Arc::clone(&text));
500+
// Update stored content
501+
self.open_files
502+
.write()
503+
.insert(uri.clone(), Arc::clone(&text));
479504

480-
// Re-parse and update AST map, use map, and namespace map
481-
let signature_changed = self.update_ast(&uri, &text);
505+
// Re-parse and update AST map, use map, and namespace map
506+
let signature_changed = self.update_ast(&uri, &text);
482507

483-
// Schedule diagnostics in a background task with debouncing.
484-
// This returns immediately so that completion, hover, and
485-
// signature help are never blocked by diagnostic computation.
486-
self.schedule_diagnostics(uri.clone());
508+
// Schedule diagnostics in a background task with debouncing.
509+
// This returns immediately so that completion, hover, and
510+
// signature help are never blocked by diagnostic computation.
511+
self.schedule_diagnostics(uri.clone());
487512

488-
// When a class signature changed (method/property added,
489-
// removed, or modified; class renamed; parent changed; etc.)
490-
// other open files may have stale diagnostics that reference
491-
// the affected classes. Queue them all for a re-check.
492-
if signature_changed {
493-
self.schedule_diagnostics_for_open_files(&uri);
494-
}
513+
// When a class signature changed (method/property added,
514+
// removed, or modified; class renamed; parent changed; etc.)
515+
// other open files may have stale diagnostics that reference
516+
// the affected classes. Queue them all for a re-check.
517+
if signature_changed {
518+
self.schedule_diagnostics_for_open_files(&uri);
495519
}
496520
}
497521

tests/integration/definition_classes.rs

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -284,10 +284,7 @@ async fn test_goto_definition_after_target_did_close() {
284284

285285
match result.unwrap() {
286286
GotoDefinitionResponse::Scalar(location) => {
287-
assert_eq!(
288-
location.uri, uri_b,
289-
"Should jump to ClassB.php"
290-
);
287+
assert_eq!(location.uri, uri_b, "Should jump to ClassB.php");
291288
}
292289
other => panic!("Expected Scalar location, got: {:?}", other),
293290
}

tests/integration/server_lifecycle.rs

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,3 +294,122 @@ async fn test_did_close_cleans_up_ast_map() {
294294
"ast_map should be cleaned up after did_close"
295295
);
296296
}
297+
298+
#[tokio::test]
299+
async fn test_did_change_incremental_sync() {
300+
let backend = create_test_backend();
301+
302+
let uri = Url::parse("file:///incremental.php").unwrap();
303+
// 0123456789...
304+
let initial = "<?php\nclass A {\n function first() {}\n}\n".to_string();
305+
306+
let open_params = DidOpenTextDocumentParams {
307+
text_document: TextDocumentItem {
308+
uri: uri.clone(),
309+
language_id: "php".to_string(),
310+
version: 1,
311+
text: initial,
312+
},
313+
};
314+
backend.did_open(open_params).await;
315+
316+
let classes = backend.get_classes_for_uri(uri.as_ref()).unwrap();
317+
assert_eq!(classes[0].methods.len(), 1);
318+
319+
// Send an incremental change: insert a second method before the closing brace.
320+
// The closing "}\n" is at line 3, col 0. Insert before it.
321+
let change_params = DidChangeTextDocumentParams {
322+
text_document: VersionedTextDocumentIdentifier {
323+
uri: uri.clone(),
324+
version: 2,
325+
},
326+
content_changes: vec![TextDocumentContentChangeEvent {
327+
range: Some(Range {
328+
start: Position {
329+
line: 3,
330+
character: 0,
331+
},
332+
end: Position {
333+
line: 3,
334+
character: 0,
335+
},
336+
}),
337+
range_length: None,
338+
text: " function second() {}\n".to_string(),
339+
}],
340+
};
341+
backend.did_change(change_params).await;
342+
343+
let classes = backend.get_classes_for_uri(uri.as_ref()).unwrap();
344+
assert_eq!(classes[0].methods.len(), 2);
345+
let names: Vec<&str> = classes[0].methods.iter().map(|m| m.name.as_str()).collect();
346+
assert!(names.contains(&"first"));
347+
assert!(names.contains(&"second"));
348+
}
349+
350+
#[tokio::test]
351+
async fn test_did_change_incremental_multiple_edits() {
352+
let backend = create_test_backend();
353+
354+
let uri = Url::parse("file:///multi_edit.php").unwrap();
355+
let initial = "<?php\nclass B {\n function alpha() {}\n}\n".to_string();
356+
357+
let open_params = DidOpenTextDocumentParams {
358+
text_document: TextDocumentItem {
359+
uri: uri.clone(),
360+
language_id: "php".to_string(),
361+
version: 1,
362+
text: initial,
363+
},
364+
};
365+
backend.did_open(open_params).await;
366+
367+
// Two incremental edits in one event: rename "alpha" to "beta" and add a method.
368+
// "alpha" starts at line 2, char 13, ends at char 18.
369+
// After that edit the file is: "<?php\nclass B {\n function beta() {}\n}\n"
370+
// Then insert before closing brace (now line 3, char 0).
371+
let change_params = DidChangeTextDocumentParams {
372+
text_document: VersionedTextDocumentIdentifier {
373+
uri: uri.clone(),
374+
version: 2,
375+
},
376+
content_changes: vec![
377+
TextDocumentContentChangeEvent {
378+
range: Some(Range {
379+
start: Position {
380+
line: 2,
381+
character: 13,
382+
},
383+
end: Position {
384+
line: 2,
385+
character: 18,
386+
},
387+
}),
388+
range_length: None,
389+
text: "beta".to_string(),
390+
},
391+
TextDocumentContentChangeEvent {
392+
range: Some(Range {
393+
start: Position {
394+
line: 3,
395+
character: 0,
396+
},
397+
end: Position {
398+
line: 3,
399+
character: 0,
400+
},
401+
}),
402+
range_length: None,
403+
text: " function gamma() {}\n".to_string(),
404+
},
405+
],
406+
};
407+
backend.did_change(change_params).await;
408+
409+
let classes = backend.get_classes_for_uri(uri.as_ref()).unwrap();
410+
assert_eq!(classes[0].methods.len(), 2);
411+
let names: Vec<&str> = classes[0].methods.iter().map(|m| m.name.as_str()).collect();
412+
assert!(names.contains(&"beta"));
413+
assert!(names.contains(&"gamma"));
414+
assert!(!names.contains(&"alpha"));
415+
}

0 commit comments

Comments
 (0)