Skip to content

Commit 86a35b9

Browse files
committed
fix: complete chains after same-line closure close
Recognize lines like `})->` as continuations when completing member access after a multi-line closure argument. This lets the resolver rebuild the full receiver instead of returning no suggestions until the chain operator is moved to a fresh line. Closes #282
1 parent 758d85e commit 86a35b9

3 files changed

Lines changed: 80 additions & 8 deletions

File tree

docs/CHANGELOG.md

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

4545
### Fixed
4646

47+
- **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.
4748
- **Deprecated enum cases are recognized.** Enum cases annotated with `@deprecated` or `#[Deprecated]` now carry deprecation metadata, so usages like `self::Low` can be highlighted as deprecated in contextual semantic-token mode. Contributed by @calebdw.
4849
- **Class constant accesses use constant semantic highlighting.** `self::CONSTANT`, `static::CONSTANT`, `parent::CONSTANT`, and `ClassName::CONSTANT` now emit the `enumMember` semantic token instead of being colored as properties, while `ClassName::$property` still emits `property`. Contributed by @calebdw.
4950
- **PHP attributes use decorator semantic highlighting.** Attribute class names in `#[...]` now emit the `decorator` semantic token instead of `class`, so editor themes can color attributes differently from normal class references. Contributed by @calebdw.

src/text_scan.rs

Lines changed: 51 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -271,10 +271,11 @@ pub(crate) fn find_matching_backward(
271271
/// Collapse multi-line method chains around the cursor into a single line.
272272
///
273273
/// When the cursor line (after trimming leading whitespace) begins with
274-
/// `->` or `?->`, this function walks backwards through preceding lines
275-
/// that are also continuations, plus the base expression line, and joins
276-
/// them into one flattened string. The returned column is the cursor's
277-
/// position within that flattened string.
274+
/// `->` or `?->`, or closes a multi-line argument immediately before one
275+
/// of those operators, this function walks backwards through preceding
276+
/// lines that are also continuations, plus the base expression line, and
277+
/// joins them into one flattened string. The returned column is the
278+
/// cursor's position within that flattened string.
278279
///
279280
/// If the cursor line is not a continuation, the original line and column
280281
/// are returned unchanged.
@@ -290,10 +291,15 @@ pub(crate) fn collapse_continuation_lines(
290291
) -> (String, usize) {
291292
let line = lines[cursor_line];
292293
let trimmed = line.trim_start();
294+
let same_line_continuation_prefix = same_line_continuation_prefix(trimmed);
293295

294-
// Only collapse when the cursor line is a continuation (starts with
295-
// `->` or `?->` after optional whitespace).
296-
if !trimmed.starts_with("->") && !trimmed.starts_with("?->") {
296+
// Only collapse when the cursor line is a continuation. Besides the
297+
// direct form (`->foo`), this includes lines like `})->foo` where the
298+
// cursor is continuing a call whose closure argument ended on this line.
299+
if !trimmed.starts_with("->")
300+
&& !trimmed.starts_with("?->")
301+
&& same_line_continuation_prefix.is_none()
302+
{
297303
return (line.to_string(), cursor_col);
298304
}
299305

@@ -336,7 +342,8 @@ pub(crate) fn collapse_continuation_lines(
336342
start -= 1;
337343

338344
// Count paren/brace balance from `start` up to (but not
339-
// including) the cursor line.
345+
// including) the cursor line. For same-line continuations
346+
// like `})->foo`, include the closers before the operator.
340347
let mut paren_depth: i32 = 0;
341348
let mut brace_depth: i32 = 0;
342349
for line in lines.iter().take(cursor_line).skip(start) {
@@ -350,6 +357,17 @@ pub(crate) fn collapse_continuation_lines(
350357
}
351358
}
352359
}
360+
if let Some(prefix) = same_line_continuation_prefix {
361+
for ch in prefix.chars() {
362+
match ch {
363+
'(' => paren_depth += 1,
364+
')' => paren_depth -= 1,
365+
'{' => brace_depth += 1,
366+
'}' => brace_depth -= 1,
367+
_ => {}
368+
}
369+
}
370+
}
353371

354372
// If balanced (or net-open), this is a proper base line.
355373
if paren_depth >= 0 && brace_depth >= 0 {
@@ -408,3 +426,28 @@ pub(crate) fn collapse_continuation_lines(
408426

409427
(prefix, new_col)
410428
}
429+
430+
fn same_line_continuation_prefix(trimmed: &str) -> Option<&str> {
431+
let mut end = 0;
432+
let mut saw_closer = false;
433+
for (idx, ch) in trimmed.char_indices() {
434+
match ch {
435+
')' | '}' | ']' => {
436+
saw_closer = true;
437+
end = idx + ch.len_utf8();
438+
}
439+
' ' | '\t' if saw_closer => {
440+
end = idx + ch.len_utf8();
441+
}
442+
'-' | '?' if saw_closer => {
443+
let rest = &trimmed[idx..];
444+
if rest.starts_with("->") || rest.starts_with("?->") {
445+
return Some(&trimmed[..end]);
446+
}
447+
return None;
448+
}
449+
_ => return None,
450+
}
451+
}
452+
None
453+
}

tests/integration/completion_multiline_chains.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -531,3 +531,31 @@ async fn test_multiline_chain_with_closure_arg() {
531531
"Should offer Collection::filter() after chain with closure args, got: {names:?}"
532532
);
533533
}
534+
535+
#[tokio::test]
536+
async fn test_multiline_chain_same_line_after_closure_arg_close() {
537+
let backend = create_test_backend();
538+
let uri = Url::parse("file:///multiline_closure_same_line.php").unwrap();
539+
let text = concat!(
540+
"<?php\n",
541+
"class Collection {\n",
542+
" public function map(callable $fn): static { return $this; }\n",
543+
" public function all(): array { return []; }\n",
544+
"}\n",
545+
"class Processor {\n",
546+
" public function items(): Collection { return new Collection(); }\n",
547+
" public function run(): void {\n",
548+
" $this->items()\n",
549+
" ->map(function ($x) {\n",
550+
" return $x;\n",
551+
" })->a\n",
552+
" }\n",
553+
"}\n",
554+
);
555+
556+
let names = complete_at(&backend, &uri, text, 11, 17).await;
557+
assert!(
558+
names.iter().any(|n| n.starts_with("all(")),
559+
"Should offer Collection::all() after same-line closure close, got: {names:?}"
560+
);
561+
}

0 commit comments

Comments
 (0)