Add code completion implementation

This commit is contained in:
Laurent Nguyen 2020-08-27 15:52:38 +02:00
parent 18745a9ae6
commit 35dbaeea96
7 changed files with 338 additions and 13 deletions

View File

@ -1,7 +1,7 @@
import { Channels } from "@nteract/messaging";
import * as monaco from "monaco-editor/esm/vs/editor/editor.api";
import * as monaco from "./monaco";
import * as React from "react";
// import { completionProvider } from "./completions/completionItemProvider";
import { completionProvider } from "./completions/completionItemProvider";
import { AppState, ContentRef } from "@nteract/core";
import { connect } from "react-redux";
import "./styles.css";
@ -81,7 +81,7 @@ function getMonacoTheme(theme: monaco.editor.IStandaloneThemeData | monaco.edito
const makeMapStateToProps = (initialState: AppState, initialProps: IMonacoProps) => {
const { id, contentRef } = initialProps;
function mapStateToProps(state: AppState, ownProps: IMonacoProps & IMonacoStateProps) {
const mapStateToProps = (state: AppState, ownProps: IMonacoProps & IMonacoStateProps) => {
return {
language: getCellMonacoLanguage(
state,
@ -303,14 +303,14 @@ export class MonacoEditor extends React.Component<IMonacoProps & IMonacoStatePro
return;
}
const { value, /* channels, language, contentRef, id, */ editorFocused, theme } = this.props;
const { value, channels, /* language, contentRef, id,*/ editorFocused, theme } = this.props;
// Ensures that the source contents of the editor (value) is consistent with the state of the editor
if (this.editor.getValue() !== value) {
this.editor.setValue(value);
}
// completionProvider.setChannels(channels);
completionProvider.setChannels(channels);
// Register Jupyter completion provider if needed
this.registerCompletionProvider();
@ -348,7 +348,7 @@ export class MonacoEditor extends React.Component<IMonacoProps & IMonacoStatePro
// In the multi-tabs scenario, when the notebook is hidden by setting "display:none",
// Any state update propagated here would cause a UI re-layout, monaco-editor will then recalculate
// and set its height to 5px.
// To work around that issue, we skip updating the UI when paraent element's offsetParent is null (which
// To work around that issue, we skip updating the UI when parent element's offsetParent is null (which
// indicate an ancient element is hidden by display set to none)
// We may revisit this when we get to refactor for multi-notebooks.
if (!this.editorContainerRef.current?.offsetParent) {
@ -394,7 +394,7 @@ export class MonacoEditor extends React.Component<IMonacoProps & IMonacoStatePro
registerDefaultCompletionProvider(language: string): void {
// onLanguage event is emitted only once per language when language is first time needed.
monaco.languages.onLanguage(language, () => {
// monaco.languages.registerCompletionItemProvider(language, completionProvider);
monaco.languages.registerCompletionItemProvider(language, completionProvider);
});
}

View File

@ -0,0 +1,239 @@
import * as monaco from "monaco-editor/esm/vs/editor/editor.api";
// import * as monaco from "../monaco";
import { Observable, Observer } from "rxjs";
import { first, map } from "rxjs/operators";
import { childOf, JupyterMessage, ofMessageType, Channels } from "@nteract/messaging";
/**
* TODO: import from nteract when the changes under editor-base.ts are ported to nteract.
*/
import { CompletionResults, CompletionMatch, completionRequest, js_idx_to_char_idx } from "../editor-base";
/**
* Jupyter to Monaco completion item kinds.
*/
const unknownJupyterKind = "<unknown>";
const jupyterToMonacoCompletionItemKind = {
[unknownJupyterKind]: monaco.languages.CompletionItemKind.Field,
class: monaco.languages.CompletionItemKind.Class,
function: monaco.languages.CompletionItemKind.Function,
keyword: monaco.languages.CompletionItemKind.Keyword,
instance: monaco.languages.CompletionItemKind.Variable,
statement: monaco.languages.CompletionItemKind.Variable
};
/**
* Completion item provider.
*/
class CompletionItemProvider implements monaco.languages.CompletionItemProvider {
private channels: Channels | undefined;
/**
* Set Channels of Jupyter kernel.
* @param channels Channels of Jupyter kernel.
*/
setChannels(channels: Channels | undefined) {
this.channels = channels;
}
/**
* Whether provider is connected to Jupyter kernel.
*/
get isConnectedToKernel() {
return !!this.channels;
}
/**
* Additional characters to trigger completion other than Ctrl+Space.
*/
get triggerCharacters() {
return [" ", "<", "/", ".", "="];
}
/**
* Get list of completion items at position of cursor.
* @param model Monaco editor text model.
* @param position Position of cursor.
*/
async provideCompletionItems(model: monaco.editor.ITextModel, position: monaco.Position) {
// Convert to zero-based index
let cursorPos = model.getOffsetAt(position);
const code = model.getValue();
cursorPos = js_idx_to_char_idx(cursorPos, code);
// Get completions from Jupyter kernel if its Channels is connected
let items = [];
if (this.channels) {
try {
const message = completionRequest(code, cursorPos);
items = await this.codeCompleteObservable(this.channels, message, model).toPromise();
} catch (error) {
// Temporary log error to console until we settle on how we log in V3
// tslint:disable-next-line
console.error(error);
}
}
return Promise.resolve<monaco.languages.CompletionList>({
suggestions: items,
incomplete: false
});
}
/**
* Get list of completion items from Jupyter kernel.
* @param channels Channels of Jupyter kernel.
* @param message Jupyter message for completion request.
* @param model Text model.
*/
private codeCompleteObservable(channels: Channels, message: JupyterMessage, model: monaco.editor.ITextModel) {
// Process completion response
const completion$ = channels.pipe(
childOf(message),
ofMessageType("complete_reply"),
map((entry) => entry.content),
first(),
map((results) => this.adaptToMonacoCompletions(results, model))
);
// Subscribe and send completion request message
return Observable.create((observer: Observer<unknown>) => {
const subscription = completion$.subscribe(observer);
channels.next(message);
return subscription;
});
}
/**
* Converts Jupyter completion result to list of Monaco completion items.
*/
private adaptToMonacoCompletions(results: CompletionResults, model: monaco.editor.ITextModel) {
let range: monaco.IRange;
let percentCount = 0;
let matches = results ? results.matches : [];
if (results.metadata && results.metadata._jupyter_types_experimental) {
matches = results.metadata._jupyter_types_experimental;
}
return matches.map((match: CompletionMatch, index: number) => {
if (typeof match === "string") {
const text = this.sanitizeText(match);
const filtered = this.getFilterText(text);
return {
kind: this.adaptToMonacoCompletionItemKind(unknownJupyterKind),
label: text,
insertText: text,
filterText: filtered,
sortText: this.getSortText(index)
} as monaco.languages.CompletionItem;
} else {
// We only need to get the range once as the range is the same for all completion items in the list.
if (!range) {
const start = model.getPositionAt(match.start);
const end = model.getPositionAt(match.end);
range = {
startLineNumber: start.lineNumber,
startColumn: start.column,
endLineNumber: end.lineNumber,
endColumn: end.column
};
// Get the range representing the text before the completion action was invoked.
// If the text starts with magics % indicator, we need to track how many of these indicators exist
// so that we ensure the insertion text only inserts the delta between what the user typed versus
// what is recommended by the completion. Without this, there will be extra % insertions.
// Example:
// User types %%p then suggestion list will recommend %%python, if we now commit the item then the
// final text in the editor becomes %%p%%python instead of %%python. This is why the tracking code
// below is needed. This behavior is only specific to the magics % indicators as Monaco does not
// handle % characters in their completion list well.
const rangeText = model.getValueInRange(range);
if (rangeText.startsWith("%%")) {
percentCount = 2;
} else if (rangeText.startsWith("%")) {
percentCount = 1;
}
}
const text = this.sanitizeText(match.text);
const filtered = this.getFilterText(text);
const insert = this.getInsertText(text, percentCount);
return {
kind: this.adaptToMonacoCompletionItemKind(match.type as keyof typeof jupyterToMonacoCompletionItemKind),
label: text,
insertText: percentCount > 0 ? insert : text,
filterText: filtered,
sortText: this.getSortText(index)
} as monaco.languages.CompletionItem;
}
});
}
/**
* Converts Jupyter completion item kind to Monaco completion item kind.
* @param kind Jupyter completion item kind.
*/
private adaptToMonacoCompletionItemKind(kind: keyof typeof jupyterToMonacoCompletionItemKind) {
const result = jupyterToMonacoCompletionItemKind[kind];
return result ? result : jupyterToMonacoCompletionItemKind[unknownJupyterKind];
}
/**
* Remove everything before a dot. Jupyter completion results like to include all characters before
* the trigger character. For example, if user types "myarray.", we expect the completion results to
* show "append", "pop", etc. but for the actual case, it will show "myarray.append", "myarray.pop",
* etc. so we are going to sanitize the text.
* @param text Text of Jupyter completion item
*/
private sanitizeText(text: string) {
const index = text.lastIndexOf(".");
return index > -1 && index < text.length - 1 ? text.substring(index + 1) : text;
}
/**
* Remove magics all % characters as Monaco doesn't like them for the filtering text.
* Without this, completion won't show magics match items.
* @param text Text of Jupyter completion item.
*/
private getFilterText(text: string) {
return text.replace(/%/g, "");
}
/**
* Get insertion text handling what to insert for the magics case depending on what
* has already been typed.
* @param text Text of Jupyter completion item.
* @param percentCount Number of percent characters to remove
*/
private getInsertText(text: string, percentCount: number) {
for (let i = 0; i < percentCount; i++) {
text = text.replace("%", "");
}
return text;
}
/**
* Maps numbers to strings, such that if a>b numerically, f(a)>f(b) lexicograhically.
* 1 -> "za", 26 -> "zz", 27 -> "zza", 28 -> "zzb", 52 -> "zzz", 53 ->"zzza"
* @param order Number to be converted to a sorting-string. order >= 0.
* @returns A string representing the order.
*/
private getSortText(order: number): string {
order++;
const numCharacters = 26; // "z" - "a" + 1;
const div = Math.floor(order / numCharacters);
let sortText = "z";
for (let i = 0; i < div; i++) {
sortText += "z";
}
const remainder = order % numCharacters;
if (remainder > 0) {
sortText += String.fromCharCode(96 + remainder);
}
return sortText;
}
}
const completionProvider = new CompletionItemProvider();
export { completionProvider };

View File

@ -1,6 +1,5 @@
import Immutable from "immutable";
import * as monaco from "monaco-editor/esm/vs/editor/editor.api";
import * as monaco from "./monaco";
/**
* Code Mirror to Monaco constants.
*/

View File

@ -0,0 +1,79 @@
// Disable linting on file since we will be moving the code below to nteract which have different rules configured.
// tslint:disable:variable-name
// tslint:disable:interface-name
/**
* TODO: Create new editor-base package in nteract repo and move all code below to new package.
*/
import { createMessage } from "@nteract/messaging";
/**
* Jupyter messaging protocol's _jupyter_types_experimental completion result.
*/
interface CompletionResult {
end: number;
start: number;
type: string;
text: string;
displayText?: string;
}
/**
* Juptyer completion match item.
*/
export type CompletionMatch = string | CompletionResult;
/**
* Jupyter messaging protocol's complete_reply response.
*/
export interface CompletionResults {
status: string;
cursor_start: number;
cursor_end: number;
matches: CompletionMatch[];
metadata?: {
_jupyter_types_experimental?: unknown;
};
}
/**
* Create Jupyter messaging protocol's complete_request message.
* @param code Code of editor.
* @param cursorPos cursor position represented in the Jupyter messaging protocol (character position)
*/
export const completionRequest = (code: string, cursorPos: number) =>
createMessage("complete_request", {
content: {
code,
cursor_pos: cursorPos
}
});
/**
* JavaScript stores text as utf16 and string indices use "code units",
* which stores high-codepoint characters as "surrogate pairs",
* which occupy two indices in the JavaScript string.
* We need to translate cursor_pos in the protocol (in characters)
* to js offset (with surrogate pairs taking two spots).
* @param js_idx JavaScript index
* @param text Text
*/
export const js_idx_to_char_idx: (js_idx: number, text: string) => number = (
js_idx: number,
text: string
): number => {
let char_idx: number = js_idx;
for (let i = 0; i + 1 < text.length && i < js_idx; i++) {
const char_code: number = text.charCodeAt(i);
// check for surrogate pair
if (char_code >= 0xd800 && char_code <= 0xdbff) {
const next_char_code: number = text.charCodeAt(i + 1);
if (next_char_code >= 0xdc00 && next_char_code <= 0xdfff) {
char_idx--;
i++;
}
}
}
return char_idx;
};

View File

@ -0,0 +1,10 @@
export * from "monaco-editor/esm/vs/editor/editor.api";
// /**
// * Set the custom worker url to workaround the cross-domain issue with creating web worker
// * See https://github.com/microsoft/monaco-editor/blob/master/docs/integrate-amd-cross.md for more details
// * This step has to be executed after a importing of monaco-editor once per chunk to make sure
// * the custom worker url overwrites the one from monaco-editor module itself.
// */
// import { setMonacoWorkerUrl } from "./workerUrl";
// setMonacoWorkerUrl();

View File

@ -1,5 +1,4 @@
// import * as monaco from "./monaco";
import * as monaco from "monaco-editor/esm/vs/editor/editor.api";
import * as monaco from "./monaco";
// TODO: move defineTheme calls to an initialization function

View File

@ -117,8 +117,7 @@ class BaseNotebookRenderer extends React.Component<NotebookRendererProps> {
{{
editor: {
codemirror: (props: PassedEditorProps) => (
// <CodeMirrorEditor {...props} lineNumbers={true} />
<MonacoEditor {...props} lineNumbers={true} enableCompletion={true} />
<MonacoEditor {...props} lineNumbers={true} enableCompletion={true} shouldRegisterDefaultCompletion={true} />
)
},
prompt: ({ id, contentRef }: { id: CellId; contentRef: ContentRef }) => (