mirror of
https://github.com/pybricks/pybricks-code.git
synced 2026-09-12 17:45:22 +00:00
editor: add basic intellisense
This adds basic intellisense for code completion and function signatures using the Python `jedi` package running in a Pyodide environment.
This commit is contained in:
@@ -3,6 +3,11 @@
|
||||
|
||||
import { createAction } from '../actions';
|
||||
import { UUID } from '../fileStorage';
|
||||
export {
|
||||
didFailToInit as editorCompletionDidFailToInit,
|
||||
didInit as editorCompletionDidInit,
|
||||
init as editorCompletionInit,
|
||||
} from './redux/codeCompletion';
|
||||
|
||||
/** Action that indicates that a code editor was created. */
|
||||
export const editorDidCreate = createAction(() => ({
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
editorDidCreate,
|
||||
editorDidOpenFile,
|
||||
} from './actions';
|
||||
import codeCompletion from './redux/codeCompletion';
|
||||
|
||||
/** Indicates that the code editor is ready for use. */
|
||||
const isReady: Reducer<boolean> = (state = false, action) => {
|
||||
@@ -46,6 +47,7 @@ const openFileUuids: Reducer<readonly UUID[]> = (state = [], action) => {
|
||||
};
|
||||
|
||||
export default combineReducers({
|
||||
codeCompletion,
|
||||
isReady,
|
||||
activeFileUuid,
|
||||
openFileUuids,
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2022 The Pybricks Authors
|
||||
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
|
||||
export enum CompletionEngineStatus {
|
||||
Unknown,
|
||||
Loading,
|
||||
Ready,
|
||||
Failed,
|
||||
}
|
||||
|
||||
type State = {
|
||||
status: CompletionEngineStatus;
|
||||
};
|
||||
|
||||
const initialState: State = {
|
||||
status: CompletionEngineStatus.Unknown,
|
||||
};
|
||||
|
||||
const slice = createSlice({
|
||||
name: 'codeCompletion',
|
||||
initialState,
|
||||
reducers: {
|
||||
init(state) {
|
||||
state.status = CompletionEngineStatus.Loading;
|
||||
},
|
||||
didInit(state) {
|
||||
state.status = CompletionEngineStatus.Ready;
|
||||
},
|
||||
didFailToInit(state) {
|
||||
state.status = CompletionEngineStatus.Failed;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { init, didInit, didFailToInit } = slice.actions;
|
||||
export default slice.reducer;
|
||||
@@ -6,6 +6,7 @@ import { EventChannel, buffers, eventChannel } from 'redux-saga';
|
||||
import {
|
||||
SagaGenerator,
|
||||
call,
|
||||
cancelled,
|
||||
delay,
|
||||
fork,
|
||||
getContext,
|
||||
@@ -26,11 +27,27 @@ import {
|
||||
fileStorageStoreTextFileValue,
|
||||
fileStorageStoreTextFileViewState,
|
||||
} from '../fileStorage/actions';
|
||||
import {
|
||||
pythonMessageComplete,
|
||||
pythonMessageDidComplete,
|
||||
pythonMessageDidFailToComplete,
|
||||
pythonMessageDidFailToGetSignature,
|
||||
pythonMessageDidFailToInit,
|
||||
pythonMessageDidGetSignature,
|
||||
pythonMessageDidInit,
|
||||
pythonMessageGetSignature,
|
||||
pythonMessageInit,
|
||||
pythonMessageSetInterruptBuffer,
|
||||
} from '../pybricksMicropython/python-message';
|
||||
import { RootState } from '../reducers';
|
||||
import { acquireLock, defined, ensureError } from '../utils';
|
||||
import { createCountFunc } from '../utils/iter';
|
||||
import {
|
||||
editorActivateFile,
|
||||
editorCloseFile,
|
||||
editorCompletionDidFailToInit,
|
||||
editorCompletionDidInit,
|
||||
editorCompletionInit,
|
||||
editorDidActivateFile,
|
||||
editorDidCloseFile,
|
||||
editorDidCreate,
|
||||
@@ -373,6 +390,278 @@ function* monitorEditors(): Generator {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a web worker with Pyodide so that we can use Jedi for intellisense.
|
||||
*/
|
||||
function* runJedi(): Generator {
|
||||
const defer = new Array<() => void>();
|
||||
|
||||
try {
|
||||
console.debug('creating code completion worker');
|
||||
|
||||
// start the web worker and set up communication channels
|
||||
|
||||
const worker = new Worker(
|
||||
new URL('../pybricksMicropython/python-worker.ts', import.meta.url),
|
||||
);
|
||||
|
||||
defer.push(() => worker.terminate());
|
||||
|
||||
const messageChannel = eventChannel<MessageEvent>((emit) => {
|
||||
worker.addEventListener('message', emit);
|
||||
|
||||
return () => worker.removeEventListener('message', emit);
|
||||
}, buffers.expanding());
|
||||
|
||||
defer.push(() => messageChannel.close());
|
||||
|
||||
const errorChannel = eventChannel<ErrorEvent>((emit) => {
|
||||
worker.addEventListener('error', emit);
|
||||
|
||||
return () => worker.removeEventListener('error', emit);
|
||||
}, buffers.expanding());
|
||||
|
||||
defer.push(() => errorChannel.close());
|
||||
|
||||
// wait for the Python runtime to start and get in a ready state
|
||||
|
||||
worker.postMessage(pythonMessageInit());
|
||||
yield* put(editorCompletionInit());
|
||||
|
||||
for (;;) {
|
||||
const { messageEvent, errorEvent } = yield* race({
|
||||
messageEvent: take(messageChannel),
|
||||
errorEvent: take(errorChannel),
|
||||
});
|
||||
|
||||
if (errorEvent) {
|
||||
yield* put(editorCompletionDidFailToInit());
|
||||
throw errorEvent.error;
|
||||
}
|
||||
|
||||
defined(messageEvent);
|
||||
|
||||
if (pythonMessageDidFailToInit.matches(messageEvent.data)) {
|
||||
yield* put(editorCompletionDidFailToInit());
|
||||
throw messageEvent.data.error;
|
||||
}
|
||||
|
||||
if (pythonMessageDidInit.matches(messageEvent.data)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
console.debug('code completion engine is ready');
|
||||
yield* put(editorCompletionDidInit());
|
||||
|
||||
// configure interrupts
|
||||
// https://pyodide.org/en/stable/usage/keyboard-interrupts.html
|
||||
|
||||
// HACK: Using WebAssembly.Memory instead of SharedArrayBuffer to avoid exception.
|
||||
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/SharedArrayBuffer/Planned_changes#api_changes
|
||||
const interrupt = new Uint8Array(
|
||||
new WebAssembly.Memory({ initial: 1, maximum: 1, shared: true }).buffer,
|
||||
);
|
||||
|
||||
const setInterrupt = () => {
|
||||
interrupt[0] = 2; //2 === SIGINT
|
||||
};
|
||||
|
||||
const clearInterrupt = () => {
|
||||
interrupt[0] = 0;
|
||||
};
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/SharedArrayBuffer#security_requirements
|
||||
if (crossOriginIsolated) {
|
||||
worker.postMessage(pythonMessageSetInterruptBuffer(interrupt));
|
||||
} else {
|
||||
console.warn(
|
||||
'required headers missing for SharedArrayBuffer, cancellation will not work',
|
||||
);
|
||||
}
|
||||
|
||||
// register intellisense hooks with editor
|
||||
|
||||
const nextId = createCountFunc();
|
||||
|
||||
const completionItemProviderChan = eventChannel<{
|
||||
model: monaco.editor.ITextModel;
|
||||
position: monaco.Position;
|
||||
context: monaco.languages.CompletionContext;
|
||||
token: monaco.CancellationToken;
|
||||
resolve: (value: monaco.languages.CompletionList | null) => void;
|
||||
}>((emit) => {
|
||||
const subscription = monaco.languages.registerCompletionItemProvider(
|
||||
pybricksMicroPythonId,
|
||||
{
|
||||
triggerCharacters: ['.', ' '],
|
||||
provideCompletionItems(
|
||||
model,
|
||||
position,
|
||||
context,
|
||||
token,
|
||||
): Promise<monaco.languages.CompletionList | null> {
|
||||
return new Promise((resolve) => {
|
||||
emit({ model, position, context, token, resolve });
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return () => subscription.dispose();
|
||||
}, buffers.expanding());
|
||||
|
||||
defer.push(() => completionItemProviderChan.close());
|
||||
|
||||
const signatureProviderChan = eventChannel<{
|
||||
model: monaco.editor.ITextModel;
|
||||
position: monaco.Position;
|
||||
token: monaco.CancellationToken;
|
||||
context: monaco.languages.SignatureHelpContext;
|
||||
resolve: (value: monaco.languages.SignatureHelpResult | null) => void;
|
||||
}>((emit) => {
|
||||
const subscription = monaco.languages.registerSignatureHelpProvider(
|
||||
pybricksMicroPythonId,
|
||||
{
|
||||
signatureHelpTriggerCharacters: ['('],
|
||||
signatureHelpRetriggerCharacters: [','],
|
||||
provideSignatureHelp(model, position, token, context) {
|
||||
return new Promise((resolve) => {
|
||||
emit({ model, position, token, context, resolve });
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return () => subscription.dispose();
|
||||
}, buffers.expanding());
|
||||
|
||||
defer.push(() => signatureProviderChan.close());
|
||||
|
||||
// Serialize requests from editor. Due to the way cancellation works, we
|
||||
// can only have one pending message from the web worker at a time.
|
||||
|
||||
for (;;) {
|
||||
const { complete, getSignature } = yield* race({
|
||||
complete: take(completionItemProviderChan),
|
||||
getSignature: take(signatureProviderChan),
|
||||
});
|
||||
|
||||
if (complete) {
|
||||
// for debugging
|
||||
const id = nextId();
|
||||
|
||||
console.debug(`${id}: requested completion item`);
|
||||
|
||||
const subscription = complete.token.onCancellationRequested(() => {
|
||||
console.debug(`${id}: requested cancelation`);
|
||||
setInterrupt();
|
||||
});
|
||||
|
||||
try {
|
||||
clearInterrupt();
|
||||
|
||||
worker.postMessage(
|
||||
pythonMessageComplete(
|
||||
complete.model.getValue(),
|
||||
complete.position.lineNumber,
|
||||
complete.position.column,
|
||||
),
|
||||
);
|
||||
|
||||
for (;;) {
|
||||
const msg = yield* take(messageChannel);
|
||||
|
||||
if (pythonMessageDidFailToComplete.matches(msg.data)) {
|
||||
if (
|
||||
msg.data.error instanceof DOMException &&
|
||||
msg.data.error.name === 'AbortError'
|
||||
) {
|
||||
console.log(`${id} canceled`);
|
||||
} else {
|
||||
console.error(msg.data.error);
|
||||
}
|
||||
complete.resolve(null);
|
||||
break;
|
||||
}
|
||||
|
||||
if (pythonMessageDidComplete.matches(msg.data)) {
|
||||
const list = JSON.parse(msg.data.completionListJson);
|
||||
console.debug(list);
|
||||
complete.resolve({ suggestions: list });
|
||||
console.debug(`${id}: resolved: ${msg.data.type}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
subscription.dispose();
|
||||
}
|
||||
} else if (getSignature) {
|
||||
// for debugging
|
||||
const id = nextId();
|
||||
|
||||
console.debug(`${id}: requested signatures`);
|
||||
|
||||
const subscription = getSignature.token.onCancellationRequested(() => {
|
||||
console.debug(`${id}: requested cancelation`);
|
||||
setInterrupt();
|
||||
});
|
||||
|
||||
try {
|
||||
clearInterrupt();
|
||||
|
||||
worker.postMessage(
|
||||
pythonMessageGetSignature(
|
||||
getSignature.model.getValue(),
|
||||
getSignature.position.lineNumber,
|
||||
getSignature.position.column,
|
||||
),
|
||||
);
|
||||
|
||||
for (;;) {
|
||||
const msg = yield* take(messageChannel);
|
||||
|
||||
if (pythonMessageDidFailToGetSignature.matches(msg.data)) {
|
||||
if (
|
||||
msg.data.error instanceof DOMException &&
|
||||
msg.data.error.name === 'AbortError'
|
||||
) {
|
||||
console.log(`${id} canceled`);
|
||||
} else {
|
||||
console.error(msg.data.error);
|
||||
}
|
||||
getSignature.resolve(null);
|
||||
break;
|
||||
}
|
||||
|
||||
if (pythonMessageDidGetSignature.matches(msg.data)) {
|
||||
const signatures = JSON.parse(msg.data.signatureHelpJson);
|
||||
console.debug(signatures);
|
||||
getSignature.resolve({
|
||||
value: signatures,
|
||||
dispose: () => undefined,
|
||||
});
|
||||
console.debug(`${id}: resolved: ${msg.data.type}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
subscription.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const isCancelled = yield* cancelled();
|
||||
|
||||
if (!isCancelled) {
|
||||
console.error(err);
|
||||
}
|
||||
} finally {
|
||||
defer.forEach((item) => item());
|
||||
}
|
||||
}
|
||||
|
||||
export default function* (): Generator {
|
||||
yield* fork(monitorEditors);
|
||||
yield* fork(runJedi);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user