Added preliminary support for parameter hints.

This commit is contained in:
Daan Vanden Bosch 2019-08-16 19:57:29 +02:00
parent 7d0d3188c2
commit 56964cb4e2
5 changed files with 337 additions and 114 deletions

View File

@ -55,6 +55,10 @@
"type": { "type": {
"$ref": "#/definitions/param_type" "$ref": "#/definitions/param_type"
}, },
"name": {
"type": "string",
"description": "Parameter name."
},
"doc": { "doc": {
"type": "string", "type": "string",
"description": "Parameter-specific documentation." "description": "Parameter-specific documentation."

View File

@ -1,14 +1,50 @@
import { action, observable } from "mobx"; import { action, observable } from "mobx";
import { editor } from "monaco-editor"; import { editor, languages } from "monaco-editor";
import AssemblyWorker from "worker-loader!./assembly_worker"; import AssemblyWorker from "worker-loader!./assembly_worker";
import { import {
AssemblyChangeInput, AssemblyChangeInput,
AssemblyWorkerOutput, AssemblyWorkerOutput,
InputMessageType,
NewAssemblyInput, NewAssemblyInput,
OutputMessageType,
SignatureHelpInput,
} from "./assembly_worker_messages"; } from "./assembly_worker_messages";
import { AssemblyError, AssemblyWarning } from "./assembly"; import { AssemblyError, AssemblyWarning } from "./assembly";
import { disassemble } from "./disassembly"; import { disassemble } from "./disassembly";
import { ObservableQuest } from "../domain/ObservableQuest"; 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 { export class AssemblyAnalyser {
@observable warnings: AssemblyWarning[] = []; @observable warnings: AssemblyWarning[] = [];
@ -16,6 +52,11 @@ export class AssemblyAnalyser {
private worker = new AssemblyWorker(); private worker = new AssemblyWorker();
private quest?: ObservableQuest; private quest?: ObservableQuest;
private promises = new Map<
number,
{ resolve: (result: any) => void; reject: (error: Error) => void }
>();
private message_id = 0;
constructor() { constructor() {
this.worker.onmessage = this.process_worker_message; this.worker.onmessage = this.process_worker_message;
@ -24,16 +65,59 @@ export class AssemblyAnalyser {
disassemble(quest: ObservableQuest): string[] { disassemble(quest: ObservableQuest): string[] {
this.quest = quest; this.quest = quest;
const assembly = disassemble(quest.object_code); 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); this.worker.postMessage(message);
return assembly; return assembly;
} }
update_assembly(changes: editor.IModelContentChange[]): void { update_assembly(changes: IModelContentChange[]): void {
const message: AssemblyChangeInput = { type: "assembly_change_input", changes }; 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); 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<SignatureHelp | undefined> {
const id = this.message_id++;
return new Promise<SignatureHelp>((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 { dispose(): void {
this.worker.terminate(); this.worker.terminate();
} }
@ -42,11 +126,66 @@ export class AssemblyAnalyser {
private process_worker_message = (e: MessageEvent): void => { private process_worker_message = (e: MessageEvent): void => {
const message: AssemblyWorkerOutput = e.data; const message: AssemblyWorkerOutput = e.data;
if (message.type === "new_object_code_output" && this.quest) { switch (message.type) {
this.quest.object_code.splice(0, this.quest.object_code.length, ...message.object_code); case OutputMessageType.NewObjectCode:
this.quest.set_map_designations(message.map_designations); if (this.quest) {
this.warnings = message.warnings; this.quest.object_code.splice(
this.errors = message.errors; 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;
} }
}; };
} }

View File

@ -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 { assemble } from "./assembly";
import Logger from "js-logger"; import Logger from "js-logger";
import { SegmentType } from "./instructions"; import { SegmentType } from "./instructions";
import { Opcode } from "./opcodes"; import { Opcode, OPCODES_BY_MNEMONIC } from "./opcodes";
import { AssemblyLexer, IdentToken, TokenType } from "./AssemblyLexer";
Logger.useDefaults({ Logger.useDefaults({
defaultLevel: (Logger as any)[process.env["LOG_LEVEL"] || "OFF"], defaultLevel: (Logger as any)[process.env["LOG_LEVEL"] || "OFF"],
@ -11,6 +20,7 @@ Logger.useDefaults({
const ctx: Worker = self as any; const ctx: Worker = self as any;
let lines: string[] = []; let lines: string[] = [];
const messages: AssemblyWorkerInput[] = []; const messages: AssemblyWorkerInput[] = [];
let timeout: any; let timeout: any;
@ -31,47 +41,96 @@ function process_messages(): void {
if (messages.length === 0) return; if (messages.length === 0) return;
for (const message of messages.splice(0, messages.length)) { for (const message of messages.splice(0, messages.length)) {
if (message.type === "new_assembly_input") { switch (message.type) {
lines = message.assembly; case InputMessageType.NewAssembly:
} else if (message.type === "assembly_change_input") { lines = message.assembly;
for (const change of message.changes) { assemble_and_send();
const { startLineNumber, endLineNumber, startColumn, endColumn } = change.range; break;
const lines_changed = endLineNumber - startLineNumber + 1; case InputMessageType.AssemblyChange:
const new_lines = change.text.split("\n"); assembly_change(message);
break;
case InputMessageType.SignatureHelp:
signature_help(message);
break;
}
}
}
if (lines_changed === 1) { function assembly_change(message: AssemblyChangeInput): void {
replace_line_part(startLineNumber, startColumn, endColumn, new_lines); for (const change of message.changes) {
} else if (new_lines.length === 1) { const { start_line_no, end_line_no, start_col, end_col, new_text } = change;
replace_lines_and_merge_line_parts( const lines_changed = end_line_no - start_line_no + 1;
startLineNumber, const new_lines = new_text.split("\n");
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]);
// Keep the right part of the last changed line. if (lines_changed === 1) {
replace_line_part_left( replace_line_part(start_line_no, start_col, end_col, new_lines);
endLineNumber, } else if (new_lines.length === 1) {
endColumn, replace_lines_and_merge_line_parts(
new_lines[new_lines.length - 1], 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. // Keep the right part of the last changed line.
// It's important that we do this last. replace_line_part_left(end_line_no, end_col, new_lines[new_lines.length - 1]);
replace_lines(
startLineNumber + 1, // Replace all the lines in between.
endLineNumber - 1, // It's important that we do this last.
new_lines.slice(1, new_lines.length - 1), 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 assembler_result = assemble(lines);
const map_designations = new Map<number, number>(); const map_designations = new Map<number, number>();
@ -90,7 +149,7 @@ function process_messages(): void {
} }
const response: NewObjectCodeOutput = { const response: NewObjectCodeOutput = {
type: "new_object_code_output", type: OutputMessageType.NewObjectCode,
map_designations, map_designations,
...assembler_result, ...assembler_result,
}; };

View File

@ -1,25 +1,56 @@
import { editor } from "monaco-editor";
import { AssemblyError, AssemblyWarning } from "./assembly"; import { AssemblyError, AssemblyWarning } from "./assembly";
import { Segment } from "./instructions"; 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 = { export type NewAssemblyInput = {
readonly type: "new_assembly_input"; readonly type: InputMessageType.NewAssembly;
readonly assembly: string[]; readonly assembly: string[];
}; };
export type AssemblyChangeInput = { export type AssemblyChangeInput = {
readonly type: "assembly_change_input"; readonly type: InputMessageType.AssemblyChange;
readonly changes: editor.IModelContentChange[]; 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 = { export type NewObjectCodeOutput = {
readonly type: "new_object_code_output"; readonly type: OutputMessageType.NewObjectCode;
readonly object_code: Segment[]; readonly object_code: Segment[];
readonly map_designations: Map<number, number>; readonly map_designations: Map<number, number>;
readonly warnings: AssemblyWarning[]; readonly warnings: AssemblyWarning[];
readonly errors: AssemblyError[]; readonly errors: AssemblyError[];
}; };
export type SignatureHelpOutput = {
readonly type: OutputMessageType.SignatureHelp;
readonly id: number;
readonly opcode?: Opcode;
readonly active_param: number;
};

View File

@ -1,12 +1,15 @@
import { autorun } from "mobx"; 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 React, { Component, createRef, ReactNode } from "react";
import { AutoSizer } from "react-virtualized"; import { AutoSizer } from "react-virtualized";
import { AssemblyAnalyser } from "../scripting/AssemblyAnalyser"; import { AssemblyAnalyser } from "../scripting/AssemblyAnalyser";
import { OPCODES } from "../scripting/opcodes";
import { quest_editor_store } from "../stores/QuestEditorStore"; import { quest_editor_store } from "../stores/QuestEditorStore";
import { Action } from "../../core/undo"; import { Action } from "../../core/undo";
import styles from "./AssemblyEditorComponent.css"; 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 = { const ASM_SYNTAX: languages.IMonarchLanguage = {
defaultToken: "invalid", defaultToken: "invalid",
@ -59,54 +62,37 @@ const ASM_SYNTAX: languages.IMonarchLanguage = {
}, },
}; };
const INSTRUCTION_SUGGESTIONS = OPCODES.filter(opcode => opcode != null).map(opcode => { const assembly_analyser = new AssemblyAnalyser();
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[];
languages.register({ id: "psoasm" }); languages.register({ id: "psoasm" });
languages.setMonarchTokensProvider("psoasm", ASM_SYNTAX); languages.setMonarchTokensProvider("psoasm", ASM_SYNTAX);
languages.registerCompletionItemProvider("psoasm", { languages.registerCompletionItemProvider("psoasm", {
provideCompletionItems: (model, position) => { provideCompletionItems(model, position): CompletionList {
const value = model.getValueInRange({ const text = model.getValueInRange({
startLineNumber: position.lineNumber, startLineNumber: position.lineNumber,
endLineNumber: position.lineNumber, endLineNumber: position.lineNumber,
startColumn: 1, startColumn: 1,
endColumn: position.column, endColumn: position.column,
}); });
const suggestions = /^\s*([a-z][a-z0-9_=<>!]*)?$/.test(value) return assembly_analyser.provide_completion_items(text);
? INSTRUCTION_SUGGESTIONS
: /^\s*\.[a-z]+$/.test(value)
? KEYWORD_SUGGESTIONS
: [];
return {
suggestions,
incomplete: false,
};
}, },
}); });
languages.registerSignatureHelpProvider("psoasm", {
signatureHelpTriggerCharacters: [" ", ","],
signatureHelpRetriggerCharacters: [", "],
provideSignatureHelp(
_model: ITextModel,
position: Position,
): Promise<SignatureHelp | undefined> {
return assembly_analyser.provide_signature_help(position.lineNumber, position.column);
},
});
languages.setLanguageConfiguration("psoasm", { languages.setLanguageConfiguration("psoasm", {
indentationRules: { indentationRules: {
increaseIndentPattern: /^\s*\d+:/, increaseIndentPattern: /^\s*\d+:/,
@ -157,8 +143,7 @@ type MonacoProps = {
class MonacoComponent extends Component<MonacoProps> { class MonacoComponent extends Component<MonacoProps> {
private div_ref = createRef<HTMLDivElement>(); private div_ref = createRef<HTMLDivElement>();
private editor?: editor.IStandaloneCodeEditor; private editor?: IStandaloneCodeEditor;
private assembly_analyser?: AssemblyAnalyser;
private disposers: (() => void)[] = []; private disposers: (() => void)[] = [];
render(): ReactNode { render(): ReactNode {
@ -177,8 +162,6 @@ class MonacoComponent extends Component<MonacoProps> {
wrappingIndent: "indent", wrappingIndent: "indent",
}); });
this.assembly_analyser = new AssemblyAnalyser();
this.disposers.push( this.disposers.push(
this.dispose, this.dispose,
autorun(this.update_model), autorun(this.update_model),
@ -209,8 +192,8 @@ class MonacoComponent extends Component<MonacoProps> {
private update_model = () => { private update_model = () => {
const quest = quest_editor_store.current_quest; const quest = quest_editor_store.current_quest;
if (quest && this.editor && this.assembly_analyser) { if (quest && this.editor) {
const assembly = this.assembly_analyser.disassemble(quest); const assembly = assembly_analyser.disassemble(quest);
const model = editor.createModel(assembly.join("\n"), "psoasm"); const model = editor.createModel(assembly.join("\n"), "psoasm");
quest_editor_store.script_undo.action = new Action( quest_editor_store.script_undo.action = new Action(
@ -260,8 +243,7 @@ class MonacoComponent extends Component<MonacoProps> {
current_version = version; current_version = version;
if (!this.assembly_analyser) return; assembly_analyser.update_assembly(e.changes);
this.assembly_analyser.update_assembly(e.changes);
}); });
this.disposers.push(() => disposable.dispose()); this.disposers.push(() => disposable.dispose());
@ -273,10 +255,11 @@ class MonacoComponent extends Component<MonacoProps> {
}; };
private update_model_markers = () => { 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. // Reference warnings and errors here to make sure we get mobx updates.
this.assembly_analyser.errors.length; assembly_analyser.warnings.length;
assembly_analyser.errors.length;
const model = this.editor.getModel(); const model = this.editor.getModel();
if (!model) return; if (!model) return;
@ -284,14 +267,25 @@ class MonacoComponent extends Component<MonacoProps> {
editor.setModelMarkers( editor.setModelMarkers(
model, model,
"psoasm", "psoasm",
this.assembly_analyser.errors.map(error => ({ assembly_analyser.warnings
severity: MarkerSeverity.Error, .map(warning => ({
message: error.message, severity: MarkerSeverity.Warning,
startLineNumber: error.line_no, message: warning.message,
endLineNumber: error.line_no, startLineNumber: warning.line_no,
startColumn: error.col, endLineNumber: warning.line_no,
endColumn: error.col + error.length, 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<MonacoProps> {
if (model) model.dispose(); if (model) model.dispose();
this.editor = undefined; this.editor = undefined;
} }
if (this.assembly_analyser) {
this.assembly_analyser.dispose();
}
}; };
} }