Skip to content

Commit

Permalink
Signature help: restructure async work (#1198)
Browse files Browse the repository at this point in the history
sendRequest isn't an async function and it can synchronously throw errors
so we need something to prevent reentrant updates in the error case.

Otherwise though we don't needed it if we move more work into the state.

I've also calculated line/column ASAP - I'm not sure this is necessary but it
feels obviously correct / straightforward which is a win in this code.
  • Loading branch information
microbit-matt-hillsdon authored Dec 9, 2024
1 parent 9e9fb88 commit 9947c9a
Showing 1 changed file with 104 additions and 58 deletions.
162 changes: 104 additions & 58 deletions src/editor/codemirror/language-server/signatureHelp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
*
* SPDX-License-Identifier: MIT
*/
import { EditorState, StateEffect, StateField } from "@codemirror/state";
import { Facet, StateEffect, StateField } from "@codemirror/state";
import {
Command,
EditorView,
Expand All @@ -22,8 +22,8 @@ import {
import { IntlShape } from "react-intl";
import {
MarkupContent,
Position,
SignatureHelp,
SignatureHelpParams,
SignatureHelpRequest,
} from "vscode-languageserver-protocol";
import { ApiReferenceMap } from "../../../documentation/mapping/content";
Expand All @@ -38,6 +38,10 @@ import {
import { nameFromSignature, removeFullyQualifiedName } from "./names";
import { offsetToPosition } from "./positions";

export const automaticFacet = Facet.define<boolean, boolean>({
combine: (values) => values[values.length - 1] ?? true,
});

export const setSignatureHelpRequestPosition = StateEffect.define<number>({});

export const setSignatureHelpResult = StateEffect.define<SignatureHelp | null>(
Expand All @@ -49,6 +53,12 @@ class SignatureHelpState {
* -1 for no signature help requested.
*/
pos: number;

/**
* The LSP position for pos.
*/
position: Position | null;

/**
* The latest result we want to display.
*
Expand All @@ -57,11 +67,16 @@ class SignatureHelpState {
*/
result: SignatureHelp | null;

constructor(pos: number, result: SignatureHelp | null) {
constructor(
pos: number,
position: Position | null,
result: SignatureHelp | null
) {
if (result && pos === -1) {
throw new Error("Invalid state");
}
this.pos = pos;
this.position = position;
this.result = result;
}
}
Expand All @@ -77,44 +92,22 @@ const signatureHelpToolTipBaseTheme = EditorView.baseTheme({
},
});

const triggerSignatureHelpRequest = async (
view: EditorView,
state: EditorState
): Promise<void> => {
const uri = state.facet(uriFacet)!;
const client = state.facet(clientFacet)!;
const pos = state.selection.main.from;
const params: SignatureHelpParams = {
textDocument: { uri },
position: offsetToPosition(state.doc, pos),
};
try {
// Must happen before other event handling that might dispatch more
// changes that invalidate our position.
queueMicrotask(() => {
view.dispatch({
effects: [setSignatureHelpRequestPosition.of(pos)],
});
});
const result = await client.connection.sendRequest(
SignatureHelpRequest.type,
params
);
view.dispatch({
effects: [setSignatureHelpResult.of(result)],
});
} catch (e) {
if (!isErrorDueToDispose(e)) {
logException(state, e, "signature-help");
}
view.dispatch({
effects: [setSignatureHelpResult.of(null)],
});
const positionEq = (a: Position | null, b: Position | null): boolean => {
if (a === null) {
return b === null;
}
if (b === null) {
return a === null;
}
return a.character === b.character && a.line === b.line;
};

const openSignatureHelp: Command = (view: EditorView) => {
triggerSignatureHelpRequest(view, view.state);
view.dispatch({
effects: [
setSignatureHelpRequestPosition.of(view.state.selection.main.from),
],
});
return true;
};

Expand All @@ -124,7 +117,7 @@ export const signatureHelp = (
apiReferenceMap: ApiReferenceMap
) => {
const signatureHelpTooltipField = StateField.define<SignatureHelpState>({
create: () => new SignatureHelpState(-1, null),
create: () => new SignatureHelpState(-1, null, null),
update(state, tr) {
let { pos, result } = state;
for (const effect of tr.effects) {
Expand All @@ -138,17 +131,45 @@ export const signatureHelp = (
}
}
}

// Even if we just got a result, if the position has been cleared we don't want it.
if (pos === -1) {
result = null;
}

// By default map the previous position forward
pos = pos === -1 ? -1 : tr.changes.mapPos(pos);
if (state.pos === pos && state.result === result) {

// Did the selection moved while open? We'll re-request but keep the old result for now.
if (pos !== -1 && tr.selection) {
pos = tr.selection.main.from;
}

// Automatic triggering cases
const automatic = tr.state.facet(automaticFacet).valueOf();
if (
automatic &&
((tr.docChanged && tr.isUserEvent("input")) ||
tr.isUserEvent("dnd.drop.call"))
) {
tr.changes.iterChanges((_fromA, _toA, _fromB, _toB, inserted) => {
if (inserted.sliceString(0).trim().endsWith("()")) {
// Triggered
pos = tr.newSelection.main.from;
}
});
}

const position = pos === -1 ? null : offsetToPosition(tr.state.doc, pos);
if (
state.pos === pos &&
state.result === result &&
positionEq(state.position, position)
) {
// Avoid pointless tooltip updates. If nothing else it makes e2e tests hard.
return state;
}
return new SignatureHelpState(pos, result);
return new SignatureHelpState(pos, position, result);
},
provide: (f) =>
showTooltip.from(f, (val) => {
Expand Down Expand Up @@ -191,30 +212,54 @@ export const signatureHelp = (
extends BaseLanguageServerView
implements PluginValue
{
constructor(view: EditorView, private automatic: boolean) {
private destroyed = false;
private lastPosition: Position | null = null;

constructor(view: EditorView) {
super(view);
}
update(update: ViewUpdate) {
if (
(update.docChanged || update.selectionSet) &&
this.view.state.field(signatureHelpTooltipField).pos !== -1
) {
triggerSignatureHelpRequest(this.view, update.state);
} else if (this.automatic && update.docChanged) {
const last = update.transactions[update.transactions.length - 1];

// This needs to trigger for autocomplete adding function parens
// as well as normal user input with `closebrackets` inserting
// the closing bracket.
if (last.isUserEvent("input") || last.isUserEvent("dnd.drop.call")) {
last.changes.iterChanges((_fromA, _toA, _fromB, _toB, inserted) => {
if (inserted.sliceString(0).trim().endsWith("()")) {
triggerSignatureHelpRequest(this.view, update.state);
const { view, state } = update;
const uri = state.facet(uriFacet)!;
const client = state.facet(clientFacet)!;
const { position } = update.state.field(signatureHelpTooltipField);
if (!positionEq(this.lastPosition, position)) {
this.lastPosition = position;
if (position !== null) {
(async () => {
try {
const result = await client.connection.sendRequest(
SignatureHelpRequest.type,
{
textDocument: { uri },
position,
}
);
if (!this.destroyed) {
view.dispatch({
effects: [setSignatureHelpResult.of(result)],
});
}
} catch (e) {
if (!isErrorDueToDispose(e)) {
logException(state, e, "signature-help");
}
// The sendRequest call can fail synchronously when disposed so we need to ensure our clean-up doesn't happen inside the CM update call.
queueMicrotask(() => {
if (!this.destroyed) {
view.dispatch({
effects: [setSignatureHelpResult.of(null)],
});
}
});
}
});
})();
}
}
}
destroy(): void {
this.destroyed = true;
}
}

const formatSignatureHelp = (
Expand Down Expand Up @@ -306,10 +351,11 @@ export const signatureHelp = (

return [
// View only handles automatic triggering.
ViewPlugin.define((view) => new SignatureHelpView(view, automatic)),
ViewPlugin.define((view) => new SignatureHelpView(view)),
signatureHelpTooltipField,
signatureHelpToolTipBaseTheme,
keymap.of(signatureHelpKeymap),
automaticFacet.of(automatic),
EditorView.domEventHandlers({
blur(event, view) {
// Close signature help as it interacts badly with drag and drop if
Expand Down

0 comments on commit 9947c9a

Please sign in to comment.