From 3f3348b08adbf02e1ad32bf1dc4214ef783aa340 Mon Sep 17 00:00:00 2001 From: Max Medvedev Date: Thu, 13 Aug 2026 16:45:56 +0200 Subject: [PATCH] IJPL-252099 measure the accepted range against the frontend item pattern Accepting a completion could leave the leading typed characters in the document (`de` + `default=` -> `dedefault=`). While a fresh request is in flight the frontend keeps the popup populated from an older request's items, re-matched against the prefix typed since (seedStaleAndSwap), and swaps them out via a restart once the fresh results land. Accepting before that restart commits an item whose backend matcher is shorter than the document prefix, and the race cannot be closed -- the user may accept at any instant -- so the accept itself has to be correct. The replaced range comes from the mirror arranger's itemPattern, which LookupArranger composes as registeredMatcherPrefix + myAdditionalPrefix. On the mirror those halves sync from different sources: the matcher from the request that produced the item, the additional prefix refreshed from the lookup on every arranger swap (refreshUi -> checkReused -> prefixChanged). Composing them is wrong in both directions -- too short while the matcher lags, and too long once the matcher is extended to the full prefix, which then swallows a space of the indent. ItemSelected therefore carries the frontend lookup's own pattern, the one string the frontend actually measured against. It is optional so a client/host version skew degrades to the previous behaviour. The mirror pins it for the committed item and returns it verbatim from itemPattern rather than rebuilding it; the matcher is still re-registered so case correction agrees with the pattern, but it no longer influences the range. An item that does not match the reported pattern is not inserted at all -- a missing completion is recoverable, a mangled one is not. The added distributed test has not been seen green: locally the lookup is torn down before its assertions by unrelated focus and completion-phase events, so it needs a CI run. (cherry picked from commit 5a9e3e26e59487027f28284dd8e3eecb2d890c55) GitOrigin-RevId: fdb36d633cfca8d7de708f8f16824110ea9c1672 --- .../common/src/protocol/RpcLookupElementEvent.kt | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/platform/completion/common/src/protocol/RpcLookupElementEvent.kt b/platform/completion/common/src/protocol/RpcLookupElementEvent.kt index 35fb75cc8bfd..1f7f0f720aab 100644 --- a/platform/completion/common/src/protocol/RpcLookupElementEvent.kt +++ b/platform/completion/common/src/protocol/RpcLookupElementEvent.kt @@ -49,17 +49,28 @@ sealed interface RpcLookupElementEvent { * older request when this event is handled (IJPL-252099 — the frontend swaps a stale-seeded request for a fresh one * right before the accept). Resolving against the arranger's own session instead would fail to find the item and * insert one carrying a shorter prefix matcher, leaving the leading typed characters in the document. + * + * [itemPattern] is the prefix the *frontend* lookup matched the chosen element against + * (`Lookup.itemPattern`), and it is the authoritative measure of the range the insertion must replace: the frontend + * owns the lookup, so it owns the lookup start offset. It can be **longer** than the matcher the backend stored for + * the item — a stale-seeded placeholder shows an older request's items re-matched against the prefix typed since + * (`FrontendCompletionRequestSessionImpl.seedStaleAndSwap`), and accepting one before the swap-restart lands would + * otherwise measure the replaced range against the older, shorter matcher and duplicate the leading typed characters + * (IJPL-252099). `null` means "no pattern reported" (nothing chosen, or an older frontend), in which case the backend + * keeps using the item's own matcher. */ @Serializable data class ItemSelected( val projectId: ProjectId, val requestId: RpcCompletionRequestId, val selectedItemId: RpcCompletionItemId? = null, + val itemPattern: String? = null, ) : RpcLookupElementEvent { override fun toString(): String = buildToString("ItemSelected") { field("projectId", projectId) field("requestId", requestId) fieldWithNullDefault("selectedItemId", selectedItemId) + fieldWithNullDefault("itemPattern", itemPattern) } }