Skip to content

Commit e502e65

Browse files
committed
String-aware completion suppression
1 parent f42d104 commit e502e65

7 files changed

Lines changed: 1518 additions & 16 deletions

File tree

docs/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2222
- **Anonymous class completion and go-to-definition.** `$this->` inside anonymous class bodies (`new class { ... }`) now resolves to the anonymous class's own members, properties, and constants. Supports `extends`, `implements`, trait `use`, constructor-promoted properties, and `@var` docblock types. Anonymous classes nested inside named class methods, closures, control flow blocks, function arguments, and return statements are all detected. `find_class_at_offset` now picks the innermost (most specific) class when scopes overlap.
2323
- **Alphabetical `use` statement insertion.** Auto-imported `use` statements are now inserted at the correct alphabetical position among existing imports instead of being appended at the bottom of the use block. This keeps the import list sorted, matching the convention expected by PSR-12, Symfony, and Laravel coding standards.
2424
- **Namespace segment completion.** When typing a namespace-qualified reference (`use App\`, `new \Illuminate\`, `\App\` in a type hint, etc.), completion now shows the next-level namespace segments as navigable MODULE-kind items alongside matching classes. Segments sort above class items so the user can drill into deep namespace trees incrementally instead of being overwhelmed by hundreds of flat class names. Works in `use` statements, `new` expressions, type hints, docblocks, `instanceof`, `extends`, `implements`, and trait `use` contexts. Segments respect the typed partial (e.g. `App\M` shows `App\Models` but not `App\Services`), simplify within the current namespace, and carry correct leading-backslash handling. In `use` import context, segments do not receive a trailing semicolon.
25+
- **String-aware completion suppression.** Completion is now suppressed inside string literals (single-quoted strings, nowdocs, and plain text in double-quoted strings and heredocs) to reduce noise from accidental triggers. Interpolation contexts are detected and allowed through: `{$expr->}` brace interpolation and simple `"$var->"` property access both produce completions as expected. Array shape key completion (`$arr['`) is unaffected.
2526

2627
### Fixed
2728

docs/todo.md

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,20 +30,45 @@ target.
3030

3131
### Remaining by user need
3232

33-
#### 27. No completion inside string interpolation
33+
#### 39. `@var` type annotation without variable name not resolved
3434

35-
Inside double-quoted strings with variable interpolation, member access
36-
completion does not work. PHP supports `"Hello $user->name"` and
37-
`"Hello {$user->getName()}"`, but the completion handler has no
38-
string-interpolation awareness. The subject extraction may accidentally
39-
work in trivial cases but is not reliable because the surrounding quote
40-
characters can confuse offset calculation and the patched content.
35+
When a `@var` docblock includes the variable name, the type is picked up
36+
correctly. When the variable name is omitted (a common shorthand), the
37+
type is not applied to the next assignment.
4138

4239
```php
43-
$greeting = "Hello {$user->}";
44-
// ^ no completion
40+
// Works:
41+
/** @var User $red */
42+
$red = a();
43+
$red-> // ← completes User members
44+
45+
// Does not work:
46+
/** @var User */
47+
$red = a();
48+
$red-> // ← no completion
4549
```
4650

51+
**Fix:** when `@var` has a type but no variable name, apply the type to
52+
the immediately following assignment statement.
53+
54+
---
55+
56+
#### 40. No nested key completion for literal array assignments
57+
58+
When a variable is assigned a literal array with nested associative
59+
keys, completing the second-level key after `$arr['first']['` does not
60+
offer suggestions. It works when an `array{...}` shape docblock is
61+
present, but not for arrays inferred purely from the literal.
62+
63+
```php
64+
$red = ['a' => ['b' => 1, 'c' => 2]];
65+
$red['a'][' // ← no completion for 'b' / 'c'
66+
```
67+
68+
**Fix:** extend the array-shape inference in `array_shape.rs` to
69+
recursively extract nested literal array structures so that multi-level
70+
key completion works without a docblock annotation.
71+
4772
---
4873

4974
#### 34 / 36. No go-to-definition for built-in (stub) functions and constants

example.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,18 @@
5858
$user->output; // @property-read (Renderable interface)
5959
$user->render(); // @method (Renderable interface)
6060

61+
62+
// ── String Interpolation ────────────────────────────────────────────────────
63+
// Completion is suppressed inside plain string content but still works in
64+
// PHP interpolation contexts. Try: delete the property name after -> and
65+
// trigger completion to see members offered.
66+
67+
$greeting = "Hello {$user->getProfile()->bio}"; // brace interpolation — completion works
68+
$info = "Name: $user->displayName"; // simple interpolation — completion works
69+
$nope = 'no $user-> here'; // single-quoted — completion suppressed
70+
$plain = "just plain text"; // no $ — completion suppressed
71+
72+
6173
// ── Parenthesized RHS Assignment ─────────────────────────────────────────────
6274
// Try completion after -> on $parenUser, which is assigned from a parenthesized expression.
6375

src/completion/comment_position.rs

Lines changed: 295 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
//! Comment and docblock position detection.
1+
//! Comment, docblock, and string position detection.
22
//!
33
//! This module provides utilities to determine whether a given cursor
4-
//! position falls inside a comment or docblock. These are used early in
5-
//! the completion pipeline to decide whether to suppress normal
6-
//! completions (inside `//` / `/* */`) or to switch to PHPDoc tag
7-
//! completion (inside `/** */`).
4+
//! position falls inside a comment, docblock, or string literal. These
5+
//! are used early in the completion pipeline to decide whether to suppress
6+
//! normal completions (inside `//` / `/* */` / string literals) or to
7+
//! switch to PHPDoc tag completion (inside `/** */`).
88
//!
99
//! The functions here are pure — they take `(content, Position)` and
1010
//! return a result without any shared state.
@@ -201,3 +201,293 @@ pub fn is_inside_non_doc_comment(content: &str, position: Position) -> bool {
201201
// Cursor is at or past end of file
202202
state == State::LineComment || state == State::BlockComment
203203
}
204+
205+
/// Classification of the string context at a given cursor position.
206+
#[derive(Debug, PartialEq, Eq)]
207+
pub enum StringContext {
208+
/// The cursor is not inside any string literal.
209+
NotInString,
210+
/// The cursor is inside a string literal where interpolation is not
211+
/// possible (single-quoted string, nowdoc) or where the cursor is at
212+
/// a position that is not an interpolation site (plain text inside a
213+
/// double-quoted string or heredoc). Completion should be suppressed.
214+
InStringLiteral,
215+
/// The cursor is inside a simple interpolation context without braces
216+
/// (`"$var->"`, `"$var"`). PHP only allows property access here (no
217+
/// method calls, no chaining), so completion should filter to
218+
/// properties only.
219+
SimpleInterpolation,
220+
/// The cursor is inside a `{$…}` brace interpolation context where
221+
/// full PHP expressions are allowed (`"{$user->getName()}"`).
222+
/// Completion should proceed normally with no filtering.
223+
BraceInterpolation,
224+
}
225+
226+
/// Classifies whether the cursor is inside a string and, if so, whether
227+
/// it is at an interpolation site where completion should still fire.
228+
///
229+
/// Returns [`StringContext::InStringLiteral`] when completion should be
230+
/// suppressed, [`StringContext::InInterpolation`] when inside a PHP
231+
/// interpolation expression, and [`StringContext::NotInString`] when the
232+
/// cursor is in normal code.
233+
pub fn classify_string_context(content: &str, position: Position) -> StringContext {
234+
let target = position_to_byte_offset(content, position);
235+
let bytes = content.as_bytes();
236+
let len = bytes.len();
237+
let mut i = 0;
238+
239+
#[derive(PartialEq, Clone, Copy)]
240+
enum State {
241+
Code,
242+
SingleString,
243+
DoubleString,
244+
LineComment,
245+
BlockComment,
246+
Docblock,
247+
Heredoc,
248+
Nowdoc,
249+
}
250+
251+
let mut state = State::Code;
252+
let mut heredoc_label: Vec<u8> = Vec::new();
253+
// Brace depth for `{$...}` interpolation inside double-quoted strings
254+
// and heredocs. Zero means we are in the string body proper; > 0 means
255+
// we are inside a `{$…}` complex interpolation expression.
256+
let mut brace_depth: u32 = 0;
257+
258+
while i < len {
259+
if i >= target {
260+
return match state {
261+
State::SingleString | State::Nowdoc => StringContext::InStringLiteral,
262+
State::DoubleString | State::Heredoc => {
263+
if brace_depth > 0 {
264+
StringContext::BraceInterpolation
265+
} else if is_simple_interpolation_site(bytes, target) {
266+
StringContext::SimpleInterpolation
267+
} else {
268+
StringContext::InStringLiteral
269+
}
270+
}
271+
_ => StringContext::NotInString,
272+
};
273+
}
274+
275+
match state {
276+
State::Code => {
277+
if bytes[i] == b'/' && i + 1 < len && bytes[i + 1] == b'/' {
278+
state = State::LineComment;
279+
i += 2;
280+
} else if bytes[i] == b'/'
281+
&& i + 2 < len
282+
&& bytes[i + 1] == b'*'
283+
&& bytes[i + 2] == b'*'
284+
&& (i + 3 >= len || bytes[i + 3] != b'*')
285+
{
286+
state = State::Docblock;
287+
i += 3;
288+
} else if bytes[i] == b'/' && i + 1 < len && bytes[i + 1] == b'*' {
289+
state = State::BlockComment;
290+
i += 2;
291+
} else if bytes[i] == b'\'' {
292+
state = State::SingleString;
293+
i += 1;
294+
} else if bytes[i] == b'"' {
295+
state = State::DoubleString;
296+
brace_depth = 0;
297+
i += 1;
298+
} else if bytes[i] == b'<'
299+
&& i + 2 < len
300+
&& bytes[i + 1] == b'<'
301+
&& bytes[i + 2] == b'<'
302+
{
303+
i += 3;
304+
while i < len && bytes[i] == b' ' {
305+
i += 1;
306+
}
307+
let is_nowdoc = i < len && bytes[i] == b'\'';
308+
if is_nowdoc {
309+
i += 1;
310+
}
311+
heredoc_label.clear();
312+
while i < len && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') {
313+
heredoc_label.push(bytes[i]);
314+
i += 1;
315+
}
316+
if !heredoc_label.is_empty() {
317+
if is_nowdoc {
318+
if i < len && bytes[i] == b'\'' {
319+
i += 1;
320+
}
321+
state = State::Nowdoc;
322+
} else {
323+
state = State::Heredoc;
324+
brace_depth = 0;
325+
}
326+
}
327+
} else {
328+
i += 1;
329+
}
330+
}
331+
State::LineComment => {
332+
if bytes[i] == b'\n' {
333+
state = State::Code;
334+
}
335+
i += 1;
336+
}
337+
State::BlockComment | State::Docblock => {
338+
if bytes[i] == b'*' && i + 1 < len && bytes[i + 1] == b'/' {
339+
state = State::Code;
340+
i += 2;
341+
} else {
342+
i += 1;
343+
}
344+
}
345+
State::SingleString => {
346+
if bytes[i] == b'\\' && i + 1 < len {
347+
i += 2;
348+
} else if bytes[i] == b'\'' {
349+
state = State::Code;
350+
i += 1;
351+
} else {
352+
i += 1;
353+
}
354+
}
355+
State::DoubleString => {
356+
if bytes[i] == b'\\' && i + 1 < len {
357+
i += 2;
358+
} else if bytes[i] == b'"' && brace_depth == 0 {
359+
state = State::Code;
360+
i += 1;
361+
} else if bytes[i] == b'{'
362+
&& ((i + 1 < len && bytes[i + 1] == b'$') || brace_depth > 0)
363+
{
364+
brace_depth += 1;
365+
i += 1;
366+
} else if bytes[i] == b'}' && brace_depth > 0 {
367+
brace_depth -= 1;
368+
i += 1;
369+
} else {
370+
i += 1;
371+
}
372+
}
373+
State::Heredoc => {
374+
if bytes[i] == b'{' && ((i + 1 < len && bytes[i + 1] == b'$') || brace_depth > 0) {
375+
brace_depth += 1;
376+
i += 1;
377+
} else if bytes[i] == b'}' && brace_depth > 0 {
378+
brace_depth -= 1;
379+
i += 1;
380+
} else if bytes[i] == b'\n' {
381+
i += 1;
382+
let line_start = i;
383+
while i < len && (bytes[i] == b' ' || bytes[i] == b'\t') {
384+
i += 1;
385+
}
386+
if i + heredoc_label.len() <= len
387+
&& &bytes[i..i + heredoc_label.len()] == heredoc_label.as_slice()
388+
{
389+
let after_label = i + heredoc_label.len();
390+
if after_label >= len
391+
|| bytes[after_label] == b';'
392+
|| bytes[after_label] == b'\n'
393+
|| bytes[after_label] == b'\r'
394+
{
395+
i = after_label;
396+
state = State::Code;
397+
brace_depth = 0;
398+
continue;
399+
}
400+
}
401+
let _ = line_start;
402+
} else {
403+
i += 1;
404+
}
405+
}
406+
State::Nowdoc => {
407+
if bytes[i] == b'\n' {
408+
i += 1;
409+
let line_start = i;
410+
while i < len && (bytes[i] == b' ' || bytes[i] == b'\t') {
411+
i += 1;
412+
}
413+
if i + heredoc_label.len() <= len
414+
&& &bytes[i..i + heredoc_label.len()] == heredoc_label.as_slice()
415+
{
416+
let after_label = i + heredoc_label.len();
417+
if after_label >= len
418+
|| bytes[after_label] == b';'
419+
|| bytes[after_label] == b'\n'
420+
|| bytes[after_label] == b'\r'
421+
{
422+
i = after_label;
423+
state = State::Code;
424+
continue;
425+
}
426+
}
427+
let _ = line_start;
428+
} else {
429+
i += 1;
430+
}
431+
}
432+
}
433+
}
434+
435+
// Cursor at or past end of file
436+
match state {
437+
State::SingleString | State::Nowdoc => StringContext::InStringLiteral,
438+
State::DoubleString | State::Heredoc => {
439+
if brace_depth > 0 {
440+
StringContext::BraceInterpolation
441+
} else if is_simple_interpolation_site(bytes, target) {
442+
StringContext::SimpleInterpolation
443+
} else {
444+
StringContext::InStringLiteral
445+
}
446+
}
447+
_ => StringContext::NotInString,
448+
}
449+
}
450+
451+
/// Checks whether the bytes immediately before `target` look like a PHP
452+
/// interpolation site inside a double-quoted string or heredoc.
453+
///
454+
/// Recognised patterns (cursor shown as `|`):
455+
/// - `$identifier->|` or `$identifier?->|` — member access
456+
/// - `$identifier|` — partially typed variable name
457+
/// - `$|` — bare dollar, user is starting an interpolation
458+
///
459+
/// All of these are valid interpolation starts in PHP double-quoted
460+
/// strings, so completion should be allowed rather than suppressed.
461+
fn is_simple_interpolation_site(bytes: &[u8], target: usize) -> bool {
462+
let mut pos = target;
463+
464+
// ── Case 1: member access `$identifier->` / `$identifier?->` ────
465+
// Check `?->` first so the longer operator is not partially matched.
466+
let has_arrow =
467+
if pos >= 3 && bytes[pos - 3] == b'?' && bytes[pos - 2] == b'-' && bytes[pos - 1] == b'>' {
468+
pos -= 3;
469+
true
470+
} else if pos >= 2 && bytes[pos - 2] == b'-' && bytes[pos - 1] == b'>' {
471+
pos -= 2;
472+
true
473+
} else {
474+
false
475+
};
476+
477+
// Walk back over the identifier `[a-zA-Z0-9_]*`.
478+
let before_ident = pos;
479+
while pos > 0 && (bytes[pos - 1].is_ascii_alphanumeric() || bytes[pos - 1] == b'_') {
480+
pos -= 1;
481+
}
482+
let ident_len = before_ident - pos;
483+
484+
// If we consumed an arrow, the identifier must be non-empty and
485+
// preceded by `$`.
486+
if has_arrow {
487+
return ident_len > 0 && pos > 0 && bytes[pos - 1] == b'$';
488+
}
489+
490+
// ── Case 2: bare `$` or `$partialName` (variable interpolation) ─
491+
// The identifier may be empty (just `$`) or partially typed.
492+
pos > 0 && bytes[pos - 1] == b'$'
493+
}

0 commit comments

Comments
 (0)