From 56964cb4e264e5a001fde5e415b27d671e2c5ec9 Mon Sep 17 00:00:00 2001 From: Daan Vanden Bosch Date: Fri, 16 Aug 2019 19:57:29 +0200 Subject: [PATCH] Added preliminary support for parameter hints. --- .../resources/scripting/opcodes.schema.json | 4 + .../scripting/AssemblyAnalyser.ts | 157 +++++++++++++++++- src/quest_editor/scripting/assembly_worker.ts | 131 +++++++++++---- .../scripting/assembly_worker_messages.ts | 45 ++++- .../ui/AssemblyEditorComponent.tsx | 114 ++++++------- 5 files changed, 337 insertions(+), 114 deletions(-) diff --git a/assets_generation/resources/scripting/opcodes.schema.json b/assets_generation/resources/scripting/opcodes.schema.json index cc380e94..128bff3a 100644 --- a/assets_generation/resources/scripting/opcodes.schema.json +++ b/assets_generation/resources/scripting/opcodes.schema.json @@ -55,6 +55,10 @@ "type": { "$ref": "#/definitions/param_type" }, + "name": { + "type": "string", + "description": "Parameter name." + }, "doc": { "type": "string", "description": "Parameter-specific documentation." diff --git a/src/quest_editor/scripting/AssemblyAnalyser.ts b/src/quest_editor/scripting/AssemblyAnalyser.ts index 290662dd..b89a6ad2 100644 --- a/src/quest_editor/scripting/AssemblyAnalyser.ts +++ b/src/quest_editor/scripting/AssemblyAnalyser.ts @@ -1,14 +1,50 @@ import { action, observable } from "mobx"; -import { editor } from "monaco-editor"; +import { editor, languages } from "monaco-editor"; import AssemblyWorker from "worker-loader!./assembly_worker"; import { AssemblyChangeInput, AssemblyWorkerOutput, + InputMessageType, NewAssemblyInput, + OutputMessageType, + SignatureHelpInput, } from "./assembly_worker_messages"; import { AssemblyError, AssemblyWarning } from "./assembly"; import { disassemble } from "./disassembly"; import { ObservableQuest } from "../domain/ObservableQuest"; +import { Kind, OPCODES } from "./opcodes"; +import CompletionList = languages.CompletionList; +import CompletionItemKind = languages.CompletionItemKind; +import CompletionItem = languages.CompletionItem; +import IModelContentChange = editor.IModelContentChange; +import SignatureHelp = languages.SignatureHelp; +import ParameterInformation = languages.ParameterInformation; + +const INSTRUCTION_SUGGESTIONS = OPCODES.filter(opcode => opcode != null).map(opcode => { + return ({ + label: opcode.mnemonic, + kind: CompletionItemKind.Function, + insertText: opcode.mnemonic, + } as any) as languages.CompletionItem; +}); + +const KEYWORD_SUGGESTIONS = [ + { + label: ".code", + kind: CompletionItemKind.Keyword, + insertText: "code", + }, + { + label: ".data", + kind: CompletionItemKind.Keyword, + insertText: "data", + }, + { + label: ".string", + kind: CompletionItemKind.Keyword, + insertText: "string", + }, +] as CompletionItem[]; export class AssemblyAnalyser { @observable warnings: AssemblyWarning[] = []; @@ -16,6 +52,11 @@ export class AssemblyAnalyser { private worker = new AssemblyWorker(); private quest?: ObservableQuest; + private promises = new Map< + number, + { resolve: (result: any) => void; reject: (error: Error) => void } + >(); + private message_id = 0; constructor() { this.worker.onmessage = this.process_worker_message; @@ -24,16 +65,59 @@ export class AssemblyAnalyser { disassemble(quest: ObservableQuest): string[] { this.quest = quest; const assembly = disassemble(quest.object_code); - const message: NewAssemblyInput = { type: "new_assembly_input", assembly }; + const message: NewAssemblyInput = { type: InputMessageType.NewAssembly, assembly }; this.worker.postMessage(message); return assembly; } - update_assembly(changes: editor.IModelContentChange[]): void { - const message: AssemblyChangeInput = { type: "assembly_change_input", changes }; + update_assembly(changes: IModelContentChange[]): void { + const message: AssemblyChangeInput = { + type: InputMessageType.AssemblyChange, + changes: changes.map(change => ({ + start_line_no: change.range.startLineNumber, + start_col: change.range.startColumn, + end_line_no: change.range.endLineNumber, + end_col: change.range.endColumn, + new_text: change.text, + })), + }; this.worker.postMessage(message); } + provide_completion_items(text: string): CompletionList { + const suggestions = /^\s*([a-z][a-z0-9_=<>!]*)?$/.test(text) + ? INSTRUCTION_SUGGESTIONS + : /^\s*\.[a-z]+$/.test(text) + ? KEYWORD_SUGGESTIONS + : []; + + return { + suggestions, + incomplete: false, + }; + } + + async provide_signature_help(line_no: number, col: number): Promise { + const id = this.message_id++; + + return new Promise((resolve, reject) => { + this.promises.set(id, { resolve, reject }); + const message: SignatureHelpInput = { + type: InputMessageType.SignatureHelp, + id, + line_no, + col, + }; + this.worker.postMessage(message); + + setTimeout(() => { + if (this.promises.delete(id)) { + reject(new Error("Signature help timed out.")); + } + }, 5_000); + }); + } + dispose(): void { this.worker.terminate(); } @@ -42,11 +126,66 @@ export class AssemblyAnalyser { private process_worker_message = (e: MessageEvent): void => { const message: AssemblyWorkerOutput = e.data; - if (message.type === "new_object_code_output" && this.quest) { - this.quest.object_code.splice(0, this.quest.object_code.length, ...message.object_code); - this.quest.set_map_designations(message.map_designations); - this.warnings = message.warnings; - this.errors = message.errors; + switch (message.type) { + case OutputMessageType.NewObjectCode: + if (this.quest) { + this.quest.object_code.splice( + 0, + this.quest.object_code.length, + ...message.object_code, + ); + this.quest.set_map_designations(message.map_designations); + this.warnings = message.warnings; + this.errors = message.errors; + } + break; + case OutputMessageType.SignatureHelp: + { + const promise = this.promises.get(message.id); + + if (promise) { + this.promises.delete(message.id); + + if (message.opcode) { + let signature = message.opcode.mnemonic + " "; + const parameters: ParameterInformation[] = []; + let first = true; + + for (const param of message.opcode.params) { + if (first) { + first = false; + } else { + signature += ", "; + } + + const param_name = Kind[param.type.kind]; + + parameters.push({ + label: [signature.length, signature.length + param_name.length], + documentation: param.doc, + }); + + signature += param_name; + } + + const help: SignatureHelp = { + signatures: [ + { + label: signature, + documentation: message.opcode.doc, + parameters, + }, + ], + activeSignature: 0, + activeParameter: message.active_param, + }; + promise.resolve(help); + } else { + promise.resolve(undefined); + } + } + } + break; } }; } diff --git a/src/quest_editor/scripting/assembly_worker.ts b/src/quest_editor/scripting/assembly_worker.ts index 3f24ddd0..57881415 100644 --- a/src/quest_editor/scripting/assembly_worker.ts +++ b/src/quest_editor/scripting/assembly_worker.ts @@ -1,8 +1,17 @@ -import { AssemblyWorkerInput, NewObjectCodeOutput } from "./assembly_worker_messages"; +import { + AssemblyChangeInput, + AssemblyWorkerInput, + InputMessageType, + NewObjectCodeOutput, + OutputMessageType, + SignatureHelpInput, + SignatureHelpOutput, +} from "./assembly_worker_messages"; import { assemble } from "./assembly"; import Logger from "js-logger"; import { SegmentType } from "./instructions"; -import { Opcode } from "./opcodes"; +import { Opcode, OPCODES_BY_MNEMONIC } from "./opcodes"; +import { AssemblyLexer, IdentToken, TokenType } from "./AssemblyLexer"; Logger.useDefaults({ defaultLevel: (Logger as any)[process.env["LOG_LEVEL"] || "OFF"], @@ -11,6 +20,7 @@ Logger.useDefaults({ const ctx: Worker = self as any; let lines: string[] = []; + const messages: AssemblyWorkerInput[] = []; let timeout: any; @@ -31,47 +41,96 @@ function process_messages(): void { if (messages.length === 0) return; for (const message of messages.splice(0, messages.length)) { - if (message.type === "new_assembly_input") { - lines = message.assembly; - } else if (message.type === "assembly_change_input") { - for (const change of message.changes) { - const { startLineNumber, endLineNumber, startColumn, endColumn } = change.range; - const lines_changed = endLineNumber - startLineNumber + 1; - const new_lines = change.text.split("\n"); + switch (message.type) { + case InputMessageType.NewAssembly: + lines = message.assembly; + assemble_and_send(); + break; + case InputMessageType.AssemblyChange: + assembly_change(message); + break; + case InputMessageType.SignatureHelp: + signature_help(message); + break; + } + } +} - if (lines_changed === 1) { - replace_line_part(startLineNumber, startColumn, endColumn, new_lines); - } else if (new_lines.length === 1) { - replace_lines_and_merge_line_parts( - startLineNumber, - endLineNumber, - startColumn, - endColumn, - new_lines[0], - ); - } else { - // Keep the left part of the first changed line. - replace_line_part_right(startLineNumber, startColumn, new_lines[0]); +function assembly_change(message: AssemblyChangeInput): void { + for (const change of message.changes) { + const { start_line_no, end_line_no, start_col, end_col, new_text } = change; + const lines_changed = end_line_no - start_line_no + 1; + const new_lines = new_text.split("\n"); - // Keep the right part of the last changed line. - replace_line_part_left( - endLineNumber, - endColumn, - new_lines[new_lines.length - 1], - ); + if (lines_changed === 1) { + replace_line_part(start_line_no, start_col, end_col, new_lines); + } else if (new_lines.length === 1) { + replace_lines_and_merge_line_parts( + start_line_no, + end_line_no, + start_col, + end_col, + new_lines[0], + ); + } else { + // Keep the left part of the first changed line. + replace_line_part_right(start_line_no, start_col, new_lines[0]); - // Replace all the lines in between. - // It's important that we do this last. - replace_lines( - startLineNumber + 1, - endLineNumber - 1, - new_lines.slice(1, new_lines.length - 1), - ); + // Keep the right part of the last changed line. + replace_line_part_left(end_line_no, end_col, new_lines[new_lines.length - 1]); + + // Replace all the lines in between. + // It's important that we do this last. + replace_lines( + start_line_no + 1, + end_line_no - 1, + new_lines.slice(1, new_lines.length - 1), + ); + } + } + + assemble_and_send(); +} + +// Hacky way of providing parameter hints. +// We just tokenize the current line and look for the first identifier and check whether it's a valid opcode. +function signature_help(message: SignatureHelpInput): void { + let opcode: Opcode | undefined; + let active_param = -1; + + if (message.line_no < lines.length) { + const line = lines[message.line_no - 1]; + const lexer = new AssemblyLexer(); + const tokens = lexer.tokenize_line(line); + const ident = tokens.find(t => t.type === TokenType.Ident) as IdentToken | undefined; + + if (ident) { + opcode = OPCODES_BY_MNEMONIC.get(ident.value); + + if (opcode) { + for (const token of tokens) { + if (token.col + token.len > message.col) { + break; + } else if (token.type === TokenType.Ident && active_param === -1) { + active_param = 0; + } else if (token.type === TokenType.ArgSeparator) { + active_param++; + } } } } } + const response: SignatureHelpOutput = { + type: OutputMessageType.SignatureHelp, + id: message.id, + opcode, + active_param, + }; + ctx.postMessage(response); +} + +function assemble_and_send(): void { const assembler_result = assemble(lines); const map_designations = new Map(); @@ -90,7 +149,7 @@ function process_messages(): void { } const response: NewObjectCodeOutput = { - type: "new_object_code_output", + type: OutputMessageType.NewObjectCode, map_designations, ...assembler_result, }; diff --git a/src/quest_editor/scripting/assembly_worker_messages.ts b/src/quest_editor/scripting/assembly_worker_messages.ts index a884f482..c1718b09 100644 --- a/src/quest_editor/scripting/assembly_worker_messages.ts +++ b/src/quest_editor/scripting/assembly_worker_messages.ts @@ -1,25 +1,56 @@ -import { editor } from "monaco-editor"; import { AssemblyError, AssemblyWarning } from "./assembly"; import { Segment } from "./instructions"; +import { Opcode } from "./opcodes"; -export type AssemblyWorkerInput = NewAssemblyInput | AssemblyChangeInput; +export enum InputMessageType { + NewAssembly, + AssemblyChange, + SignatureHelp, +} + +export type AssemblyWorkerInput = NewAssemblyInput | AssemblyChangeInput | SignatureHelpInput; export type NewAssemblyInput = { - readonly type: "new_assembly_input"; + readonly type: InputMessageType.NewAssembly; readonly assembly: string[]; }; export type AssemblyChangeInput = { - readonly type: "assembly_change_input"; - readonly changes: editor.IModelContentChange[]; + readonly type: InputMessageType.AssemblyChange; + readonly changes: { + start_line_no: number; + start_col: number; + end_line_no: number; + end_col: number; + new_text: string; + }[]; }; -export type AssemblyWorkerOutput = NewObjectCodeOutput; +export type SignatureHelpInput = { + readonly type: InputMessageType.SignatureHelp; + readonly id: number; + readonly line_no: number; + readonly col: number; +}; + +export enum OutputMessageType { + NewObjectCode, + SignatureHelp, +} + +export type AssemblyWorkerOutput = NewObjectCodeOutput | SignatureHelpOutput; export type NewObjectCodeOutput = { - readonly type: "new_object_code_output"; + readonly type: OutputMessageType.NewObjectCode; readonly object_code: Segment[]; readonly map_designations: Map; readonly warnings: AssemblyWarning[]; readonly errors: AssemblyError[]; }; + +export type SignatureHelpOutput = { + readonly type: OutputMessageType.SignatureHelp; + readonly id: number; + readonly opcode?: Opcode; + readonly active_param: number; +}; diff --git a/src/quest_editor/ui/AssemblyEditorComponent.tsx b/src/quest_editor/ui/AssemblyEditorComponent.tsx index 1d4e180e..df7d9507 100644 --- a/src/quest_editor/ui/AssemblyEditorComponent.tsx +++ b/src/quest_editor/ui/AssemblyEditorComponent.tsx @@ -1,12 +1,15 @@ import { autorun } from "mobx"; -import { editor, languages, MarkerSeverity } from "monaco-editor"; +import { editor, languages, MarkerSeverity, Position } from "monaco-editor"; import React, { Component, createRef, ReactNode } from "react"; import { AutoSizer } from "react-virtualized"; import { AssemblyAnalyser } from "../scripting/AssemblyAnalyser"; -import { OPCODES } from "../scripting/opcodes"; import { quest_editor_store } from "../stores/QuestEditorStore"; import { Action } from "../../core/undo"; import styles from "./AssemblyEditorComponent.css"; +import CompletionList = languages.CompletionList; +import ITextModel = editor.ITextModel; +import IStandaloneCodeEditor = editor.IStandaloneCodeEditor; +import SignatureHelp = languages.SignatureHelp; const ASM_SYNTAX: languages.IMonarchLanguage = { defaultToken: "invalid", @@ -59,54 +62,37 @@ const ASM_SYNTAX: languages.IMonarchLanguage = { }, }; -const INSTRUCTION_SUGGESTIONS = OPCODES.filter(opcode => opcode != null).map(opcode => { - return ({ - label: opcode.mnemonic, - kind: languages.CompletionItemKind.Function, - insertText: opcode.mnemonic, - } as any) as languages.CompletionItem; -}); - -const KEYWORD_SUGGESTIONS = [ - { - label: ".code", - kind: languages.CompletionItemKind.Keyword, - insertText: "code", - }, - { - label: ".data", - kind: languages.CompletionItemKind.Keyword, - insertText: "data", - }, - { - label: ".string", - kind: languages.CompletionItemKind.Keyword, - insertText: "string", - }, -] as languages.CompletionItem[]; +const assembly_analyser = new AssemblyAnalyser(); languages.register({ id: "psoasm" }); + languages.setMonarchTokensProvider("psoasm", ASM_SYNTAX); + languages.registerCompletionItemProvider("psoasm", { - provideCompletionItems: (model, position) => { - const value = model.getValueInRange({ + provideCompletionItems(model, position): CompletionList { + const text = model.getValueInRange({ startLineNumber: position.lineNumber, endLineNumber: position.lineNumber, startColumn: 1, endColumn: position.column, }); - const suggestions = /^\s*([a-z][a-z0-9_=<>!]*)?$/.test(value) - ? INSTRUCTION_SUGGESTIONS - : /^\s*\.[a-z]+$/.test(value) - ? KEYWORD_SUGGESTIONS - : []; - - return { - suggestions, - incomplete: false, - }; + return assembly_analyser.provide_completion_items(text); }, }); + +languages.registerSignatureHelpProvider("psoasm", { + signatureHelpTriggerCharacters: [" ", ","], + + signatureHelpRetriggerCharacters: [", "], + + provideSignatureHelp( + _model: ITextModel, + position: Position, + ): Promise { + return assembly_analyser.provide_signature_help(position.lineNumber, position.column); + }, +}); + languages.setLanguageConfiguration("psoasm", { indentationRules: { increaseIndentPattern: /^\s*\d+:/, @@ -157,8 +143,7 @@ type MonacoProps = { class MonacoComponent extends Component { private div_ref = createRef(); - private editor?: editor.IStandaloneCodeEditor; - private assembly_analyser?: AssemblyAnalyser; + private editor?: IStandaloneCodeEditor; private disposers: (() => void)[] = []; render(): ReactNode { @@ -177,8 +162,6 @@ class MonacoComponent extends Component { wrappingIndent: "indent", }); - this.assembly_analyser = new AssemblyAnalyser(); - this.disposers.push( this.dispose, autorun(this.update_model), @@ -209,8 +192,8 @@ class MonacoComponent extends Component { private update_model = () => { const quest = quest_editor_store.current_quest; - if (quest && this.editor && this.assembly_analyser) { - const assembly = this.assembly_analyser.disassemble(quest); + if (quest && this.editor) { + const assembly = assembly_analyser.disassemble(quest); const model = editor.createModel(assembly.join("\n"), "psoasm"); quest_editor_store.script_undo.action = new Action( @@ -260,8 +243,7 @@ class MonacoComponent extends Component { current_version = version; - if (!this.assembly_analyser) return; - this.assembly_analyser.update_assembly(e.changes); + assembly_analyser.update_assembly(e.changes); }); this.disposers.push(() => disposable.dispose()); @@ -273,10 +255,11 @@ class MonacoComponent extends Component { }; private update_model_markers = () => { - if (!this.editor || !this.assembly_analyser) return; + if (!this.editor) return; - // Reference errors here to make sure we get mobx updates. - this.assembly_analyser.errors.length; + // Reference warnings and errors here to make sure we get mobx updates. + assembly_analyser.warnings.length; + assembly_analyser.errors.length; const model = this.editor.getModel(); if (!model) return; @@ -284,14 +267,25 @@ class MonacoComponent extends Component { editor.setModelMarkers( model, "psoasm", - this.assembly_analyser.errors.map(error => ({ - severity: MarkerSeverity.Error, - message: error.message, - startLineNumber: error.line_no, - endLineNumber: error.line_no, - startColumn: error.col, - endColumn: error.col + error.length, - })), + assembly_analyser.warnings + .map(warning => ({ + severity: MarkerSeverity.Warning, + message: warning.message, + startLineNumber: warning.line_no, + endLineNumber: warning.line_no, + startColumn: warning.col, + endColumn: warning.col + warning.length, + })) + .concat( + assembly_analyser.errors.map(error => ({ + severity: MarkerSeverity.Error, + message: error.message, + startLineNumber: error.line_no, + endLineNumber: error.line_no, + startColumn: error.col, + endColumn: error.col + error.length, + })), + ), ); }; @@ -302,9 +296,5 @@ class MonacoComponent extends Component { if (model) model.dispose(); this.editor = undefined; } - - if (this.assembly_analyser) { - this.assembly_analyser.dispose(); - } }; }