From aab38738d8c15e4f607a1c93abdc50cb93090147 Mon Sep 17 00:00:00 2001 From: philer Date: Mon, 29 Nov 2021 20:46:09 +0100 Subject: [PATCH] Make parser feedback easier to understand * add en-informal and de-informal localization * replace expected token types with token values in parser errors * move keywords to localization data * fix parser ignoring trailing asterisc statements * move Exception class out of localization --- config.js | 2 +- localization/de-informal.js | 41 +++++ localization/de.js | 23 ++- localization/en-informal.js | 41 +++++ localization/en.js | 25 ++- src/App.tsx | 8 +- src/exception.ts | 13 ++ src/language/highlight.ts | 2 +- src/language/interpreter.ts | 2 +- src/language/parser.ts | 62 ++++--- src/language/tokens.test.ts | 311 ++++++++++++++++++----------------- src/language/tokens.ts | 69 ++++---- src/localization.ts | 65 +++----- src/simulation/simulation.ts | 11 ++ src/simulation/world.ts | 2 +- src/ui/Editor.tsx | 10 +- src/ui/Logging.tsx | 8 +- src/ui/Main.tsx | 3 +- src/ui/WorldControls.tsx | 5 +- src/util/index.ts | 7 + 20 files changed, 438 insertions(+), 272 deletions(-) create mode 100644 localization/de-informal.js create mode 100644 localization/en-informal.js create mode 100644 src/exception.ts diff --git a/config.js b/config.js index 67b95ef..bfa38ad 100644 --- a/config.js +++ b/config.js @@ -2,7 +2,7 @@ config({ // Must be available as a localization/*.js file. // Use an array to specify fallback options, e.g. ["de", "en"] // Use "auto" to detect browser locale. - locale: ["auto", "en"], + locale: ["auto-informal", "auto", "en"], // graphics sprite themes tile_theme: "themes/neoz7", diff --git a/localization/de-informal.js b/localization/de-informal.js new file mode 100644 index 0000000..6038e00 --- /dev/null +++ b/localization/de-informal.js @@ -0,0 +1,41 @@ +/** + * Kids friendly German localization variables. + * Enable by setting locale: "de-informal" in config.js. + */ +config({ + simulation: { + run: "Los!", + }, + error: { + parser: { + token_read: + "Ich verstehe nicht, was du in Zeile {line} meinst.", + unexpected_eof: + "Das Programm sollte noch weitergehen.", + unexpected_eof_instead: + "Das Programm sollte noch weitergehen. Als nächstes würde ich {expected} schreiben.", + unexpected_token: + "Ich verstehe nicht, was du mit '{value}' in Zeile {line} meinst.", + unexpected_token_instead: + "Ich verstehe nicht, was was du in Zeile {line} meinst. Du hast '{value}' geschrieben, aber ich habe {expected} erwartet.", + nested_program_definition: + "Ein Programm darf nicht in einem anderen Block stehen.", + nested_routine_definition: + "Eine Anweisung darf nicht in einem anderen Block stehen.", + }, + runtime: { + undefined: "Den Befehl '{identifier}' kenne ich nicht.", + }, + world: { + move_out_of_world: "Ich kann nicht über den Rand meiner Welt laufen.", + jump_too_high: "So hoch kann ich nicht springen.", + move_cuboid: "Auf einen Quader kann ich mich nicht stellen.", + action_out_of_world: "Ich kann Ziegel nicht aus meiner Welt hinauswerfen.", + action_cuboid: "Auf einen Quader kann ich keine Ziegel legen.", + action_too_high: "Ich kann nicht höher stapeln.", + action_no_blocks: "Hier gibt es keine Ziegel zum aufheben.", + action_already_marked: "Das Feld ist schon markiert.", + action_no_mark: "Hier gibt es keine Markierung zum löschen.", + }, + }, +}) diff --git a/localization/de.js b/localization/de.js index 1d9f3b1..6ea3798 100644 --- a/localization/de.js +++ b/localization/de.js @@ -3,8 +3,9 @@ * Enable by setting locale: "de" in config.js. */ config({ - welcome: "Willkommen! 👋🤖 Ich bin online Karol v{version}. Du kannst auch eine {older_release} ausprobieren.", + welcome: "Hallo! 👋🤖 Ich bin online Karol Version {version}. Du kannst auch eine {older_release} ausprobieren.", older_release: "ältere Version", + or: "oder", program: { code: "Programm", save: "Speichern", @@ -43,20 +44,36 @@ config({ turnRight: "Nach rechts drehen", }, }, + language: { + IF: "wenn", + THEN: "dann", + ELSE: "sonst", + WHILE: "solange", + DO: "tue", + NOT: "nicht", + REPEAT: "wiederhole", + TIMES: "mal", + PROGRAM: "programm", + ROUTINE: "anweisung", + }, error: { browser_feature_not_available: "Der Browser ist veraltet und unterstützt diese Funktionalität nicht.", invalid_world_file: "Das ist keine valide *.kdw Datei.", parser: { token_read: "Syntax-Fehler in Zeile {line}, Spalte {column}: Nächstes Wort nicht lesbar.", + unexpected_eof: + "Lese-Fehler: Unerwartetes End der Eingabe.", + unexpected_eof_instead: + "Lese-Fehler: Unerwartetes End der Eingabe, erwarte stattdessen {expected}.", unexpected_token: "Lese-Fehler in Zeile {line}, Spalte {column}: Unerwartes Wort '{value}'.", unexpected_token_instead: "Lese-Fehler in Zeile {line}, Spalte {column}: Unerwartes Wort '{value}', erwarte stattdessen {expected} .", nested_program_definition: - "Lese-Fehler in Zeile {}: Programm in verschachteltem Kontext nicht definierbar.", + "Lese-Fehler in Zeile {line}: Programm in verschachteltem Kontext nicht definierbar.", nested_routine_definition: - "Lese-Fehler in Zeile {}: Anweisung in verschachteltem Kontext nicht definierbar.", + "Lese-Fehler in Zeile {line}: Anweisung in verschachteltem Kontext nicht definierbar.", }, runtime: { undefined: "Laufzeit-Fehler in Zeile {line}: {identifier} nicht definiert.", diff --git a/localization/en-informal.js b/localization/en-informal.js new file mode 100644 index 0000000..2f6d5ec --- /dev/null +++ b/localization/en-informal.js @@ -0,0 +1,41 @@ +/** + * Kids friendly English localization variables. + * Enable by setting locale: "de" in config.js. + */ +config({ + simulation: { + run: "Go!", + }, + error: { + parser: { + token_read: + "I don't understand what you mean on line {line}.", + unexpected_eof: + "The program isn't finished yet.", + unexpected_eof_instead: + "The program isn't finished yet. Next I'd write {expected}.", + unexpected_token: + "I don't understand what you mean by '{value}' on line {line}.", + unexpected_token_instead: + "I don't understand what you mean on line {line}. You wrote '{value}', but I was expecting {expected}.", + nested_program_definition: + "A Programm can't be inside another block.", + nested_routine_definition: + "A Routine can't be inside another block.", + }, + runtime: { + undefined: "I don't know the command '{identifier}'.", + }, + world: { + move_out_of_world: "I can't walk over the edge of my world.", + jump_too_high: "I can't jump that high.", + move_cuboid: "I can't stand on a cuboid.", + action_out_of_world: "I can't throw bricks out of my world.", + action_cuboid: "I can't place bricks on top of a cuboid.", + action_too_high: "I can't stack any higher.", + action_no_blocks: "There are no bricks here to pick up.", + action_already_marked: "This field is already marked.", + action_no_mark: "There is no mark here to remove.", + }, + }, +}) diff --git a/localization/en.js b/localization/en.js index 551cd88..cb0768d 100644 --- a/localization/en.js +++ b/localization/en.js @@ -6,8 +6,9 @@ * and serve as reference for other languages. */ config({ - welcome: "Welcome! 👋🤖 I am online Karol v{version}. You can also try an {older_release}.", + welcome: "Welcome! 👋🤖 I am online Karol version {version}. You can also try an {older_release}.", older_release: "older release", + or: "or", program: { code: "Code", save: "Save", @@ -46,20 +47,36 @@ config({ turnRight: "Turn right", }, }, + language: { + IF: "if", + THEN: "then", + ELSE: "else", + WHILE: "while", + DO: "do", + NOT: "not", + REPEAT: "repeat", + TIMES: "times", + PROGRAM: "program", + ROUTINE: "routine", + }, error: { browser_feature_not_available: "Your browser does not support his feature. Consider switch to an up-to-date browser.", invalid_world_file: "This does not appear to be a valid *.kdw file.", parser: { token_read: "Syntax Error on line {line}, column {column}: Could not read next token.", + unexpected_eof: + "Parse Error: Unexpected end of input.", + unexpected_eof_instead: + "Parse Error: Unexpected end of input, was expecting {expected}", unexpected_token: "Parse Error on line {line}, column {column}: Unexpected token '{value}'.", unexpected_token_instead: - "Parse Error on line {line}, column {column}: Unexpected token '{value}', was expecting {expected} .", + "Parse Error on line {line}, column {column}: Unexpected token '{value}', was expecting {expected}.", nested_program_definition: - "Parse Error on line {}: Can't define program in nested context.", + "Parse Error on line {line}: Can't define program in nested context.", nested_routine_definition: - "Parse Error on line {}: Can't define routine in nested context.", + "Parse Error on line {line}: Can't define routine in nested context.", }, runtime: { undefined: "Runtime Error on line {line}: {identifier} is not defined.", diff --git a/src/App.tsx b/src/App.tsx index 658a47f..1f69077 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,9 +2,9 @@ import {h, render} from "preact" import {useContext, useEffect, useErrorBoundary, useState} from "preact/hooks" import "./global.css" -import * as graphics from "./graphics" +import {init as initGraphics} from "./graphics" import {init as initLocalization, translate as t} from "./localization" -import * as editor from "./ui/Editor" +import {init as initEditor} from "./ui/Editor" import {Main} from "./ui/Main" import {Logging, LoggingProvider} from "./ui/Logging" import {Translate} from "./ui/Translate" @@ -15,9 +15,9 @@ import * as style from "./App.module.css" const initPromises = Promise.all([ - graphics.init(), + initGraphics(), + initEditor(), initLocalization(), - editor.loadTheme(), ]) diff --git a/src/exception.ts b/src/exception.ts new file mode 100644 index 0000000..a989079 --- /dev/null +++ b/src/exception.ts @@ -0,0 +1,13 @@ +/** + * Errors for our simulated programming language and runtime. + * These do not inherit from JS's own Error as they do not need + * to reveal details of the interpreter/runtime internals. + */ +export class Exception { + message: string + data: any[] + constructor(message: string, ...data: any[]) { + this.message = message + this.data = data + } +} diff --git a/src/language/highlight.ts b/src/language/highlight.ts index dc15cd9..6d8df22 100644 --- a/src/language/highlight.ts +++ b/src/language/highlight.ts @@ -1,4 +1,4 @@ -import {Exception} from "../localization" +import {Exception} from "../exception" import {clsx} from "../util" import * as tokens from "./tokens" diff --git a/src/language/interpreter.ts b/src/language/interpreter.ts index 2fd0686..d457daa 100644 --- a/src/language/interpreter.ts +++ b/src/language/interpreter.ts @@ -1,4 +1,4 @@ -import {Exception} from "../localization" +import {Exception} from "../exception" import * as tokens from "./tokens" import {Call, Expression, RoutineDefinition, Sequence, Statement, textToAst} from "./parser" diff --git a/src/language/parser.ts b/src/language/parser.ts index ed6353f..3568127 100644 --- a/src/language/parser.ts +++ b/src/language/parser.ts @@ -1,8 +1,10 @@ -import {Exception} from "../localization" - +import {Exception} from "../exception" +import {commaList} from "../util" +import {translate} from "../localization" import * as tokens from "./tokens" // eslint-disable-next-line no-duplicate-imports -import {Token, TokenType, tokenize} from "./tokens" +import {Token, TokenType, tokenTypeToLiteral, tokenize} from "./tokens" + export interface AbstractStatement { type: TokenType @@ -86,10 +88,7 @@ export class Parser { this.forward() return type } else { - throw new Exception("error.parser.unexpected_token_instead", { - ...this.currentToken, - expected: types, - }) + throw error("error.parser.unexpected_token_instead", this.currentToken, types) } } @@ -108,10 +107,7 @@ export class Parser { this.forward() return value } - throw new Exception("error.parser.unexpected_token_instead", { - ...this.currentToken, - expected: types, - }) + throw error("error.parser.unexpected_token_instead", this.currentToken, types) } readExpression(): Expression { @@ -138,7 +134,7 @@ export class Parser { return expr } } - throw new Exception("error.parser.unexpected_token", this.currentToken) + throw error("error.parser.unexpected_token", this.currentToken) } readCall(): Call { @@ -158,9 +154,8 @@ export class Parser { return call } - readSequence(): Sequence { + readSequence(...endTokens: [TokenType, ...TokenType[]]): Sequence { const statements: Sequence = [] - const endTokens: TokenType[] = [tokens.ASTERISC, tokens.ELSE, tokens.EOF] this.depth++ while (!endTokens.includes(this.currentToken.type)) { statements.push(this.readStatement()) @@ -180,11 +175,11 @@ export class Parser { this.forward() const condition = this.readExpression() this.eat(tokens.THEN) - const sequence = this.readSequence() + const sequence = this.readSequence(tokens.ELSE, tokens.ASTERISC) const statement: IfStatement = {type, line, condition, sequence} if (this.currentToken.type === tokens.ELSE) { this.forward() - statement.alternative = this.readSequence() + statement.alternative = this.readSequence(tokens.ASTERISC) } this.eat(tokens.ASTERISC) this.eat(tokens.IF) @@ -195,7 +190,7 @@ export class Parser { this.forward() const condition = this.readExpression() this.eat(tokens.DO) - const sequence = this.readSequence() + const sequence = this.readSequence(tokens.ASTERISC) this.eat(tokens.ASTERISC) this.eat(tokens.WHILE) return {type, line, condition, sequence} @@ -211,12 +206,12 @@ export class Parser { type: tokens.WHILE, line, condition: this.readExpression(), - sequence: this.readSequence(), + sequence: this.readSequence(tokens.ASTERISC), } } else { const count = this.readExpression() this.eat(tokens.TIMES) - statement = {type, line, count, sequence: this.readSequence()} + statement = {type, line, count, sequence: this.readSequence(tokens.ASTERISC)} } this.eat(tokens.ASTERISC) this.eat(tokens.REPEAT) @@ -225,11 +220,10 @@ export class Parser { case tokens.PROGRAM: { if (this.depth > 1) { - throw new Exception("error.parser.nested_program_definition", - this.currentToken.line) + throw error("error.parser.nested_program_definition", this.currentToken) } this.forward() - const sequence = this.readSequence() + const sequence = this.readSequence(tokens.ASTERISC) this.eat(tokens.ASTERISC) this.eat(tokens.PROGRAM) return {type, line, sequence} @@ -237,8 +231,7 @@ export class Parser { case tokens.ROUTINE: { if (this.depth > 1) { - throw new Exception("error.parser.nested_program_definition", - this.currentToken.line) + throw error("error.parser.nested_program_definition", this.currentToken) } this.forward() const identifier = this.readToken(tokens.IDENTIFIER) @@ -249,16 +242,31 @@ export class Parser { argNames.push(this.readToken(tokens.IDENTIFIER)) } } - const sequence = this.readSequence() + const sequence = this.readSequence(tokens.ASTERISC) this.eat(tokens.ASTERISC) this.eat(tokens.ROUTINE) return {type, line, identifier, argNames, sequence} } } - throw new Exception("error.parser.unexpected_token", this.currentToken) + throw error("error.parser.unexpected_token", this.currentToken) } } + +function error(key: string, token: Token, expected: TokenType[] = []) { + if (token.type === tokens.EOF) { + key = expected.length ? "error.parser.unexpected_eof_instead" : "error.parser.unexpected_eof" + } + return new Exception(key, { + ...token, + expected: commaList( + expected.map(tt => `'${tokenTypeToLiteral.get(tt) || tt}'`), + ` ${translate("or")} `, + ), + }) +} + + /** Convenience funtion turns code into an abstract syntax tree. */ export const textToAst = (text: string) => - new Parser(tokenize(text)).readSequence() + new Parser(tokenize(text)).readSequence(tokens.EOF) diff --git a/src/language/tokens.test.ts b/src/language/tokens.test.ts index 379e1b7..efccf2e 100644 --- a/src/language/tokens.test.ts +++ b/src/language/tokens.test.ts @@ -1,172 +1,175 @@ import {tokenize} from "./tokens" -test("tokenize empty program", () => { - expect(Array.from(tokenize(""))) - .toEqual([]) - expect(Array.from(tokenize("", true, true))) - .toEqual([]) -}) +describe("tokenizer", () => { -test("tokenize identifiers", () => { - expect(Array.from(tokenize("ascii"))) - .toEqual([{type: "IDENTIFIER", value: "ascii", line: 1, column: 1}]) - const utf8 = "ÁéèôæçäöüÄÖÜß" - expect(Array.from(tokenize(utf8))) - .toEqual([{type: "IDENTIFIER", value: utf8, line: 1, column: 1}]) -}) + test("empty program", () => { + expect(Array.from(tokenize(""))) + .toEqual([]) + expect(Array.from(tokenize("", true, true))) + .toEqual([]) + }) -test("tokenize integers", () => { - expect(Array.from(tokenize("0"))) - .toEqual([{type: "INTEGER", value: "0", line: 1, column: 1}]) - expect(Array.from(tokenize("1234567890"))) - .toEqual([{type: "INTEGER", value: "1234567890", line: 1, column: 1}]) - expect(Array.from(tokenize("00123"))) - .toEqual([{type: "INTEGER", value: "00123", line: 1, column: 1}]) -}) + test("identifiers", () => { + expect(Array.from(tokenize("ascii"))) + .toEqual([{type: "IDENTIFIER", value: "ascii", line: 1, column: 1}]) + const utf8 = "ÁéèôæçäöüÄÖÜß" + expect(Array.from(tokenize(utf8))) + .toEqual([{type: "IDENTIFIER", value: utf8, line: 1, column: 1}]) + }) -test('tokenize program "mauer"', () => { - expect(Array.from(tokenize(code, true, true))).toEqual(tokens) - expect(Array.from(tokenize(code))).toEqual( - tokens.filter(({type}) => type !== "WHITESPACE" && type !== "COMMENT"), - ) -}) + test("integers", () => { + expect(Array.from(tokenize("0"))) + .toEqual([{type: "INTEGER", value: "0", line: 1, column: 1}]) + expect(Array.from(tokenize("1234567890"))) + .toEqual([{type: "INTEGER", value: "1234567890", line: 1, column: 1}]) + expect(Array.from(tokenize("00123"))) + .toEqual([{type: "INTEGER", value: "00123", line: 1, column: 1}]) + }) + + test("program 'wall'", () => { + expect(Array.from(tokenize(code, true, true))).toEqual(tokens) + expect(Array.from(tokenize(code))).toEqual( + tokens.filter(({type}) => type !== "WHITESPACE" && type !== "COMMENT"), + ) + }) -test("tokenizer returns EOF", () => { - expect(tokenize("").next()) - .toEqual({done: true, value: {type: "EOF", value: "", line: 1, column: 1}}) + test("returns EOF", () => { + expect(tokenize("").next()) + .toEqual({done: true, value: {type: "EOF", value: "", line: 1, column: 1}}) + }) }) -const code = `{ Legt eine Mauer rund um die Welt } +const code = `{ Build a wall around the world } -anweisung mauer() - solange nicht IstWand() tue - hinlegen(2) - schritt() - *solange -*anweisung +routine wall() + while not isLookingAtEdge() do + placeBlock(2) + step() + *while +*routine -// Treppe -hinlegen() -schritt() -hinlegen(2) -schritt() -rechtsDrehen() rechtsDrehen() -hinlegen() -linksDrehen() linksDrehen() +// Stairs +placeBlock() +step() +placeBlock(2) +step() +turnRight() turnRight() +placeBlock() +turnLeft() turnLeft() -mauer() -linksDrehen() -mauer() -linksDrehen() -mauer() -linksDrehen() -mauer() -linksDrehen() +wall() +turnLeft() +wall() +turnLeft() +wall() +turnLeft() +wall() +turnLeft() ` const tokens = [ - {type: "COMMENT", value: "{ Legt eine Mauer rund um die Welt }", line: 1, column: 1}, - {type: "WHITESPACE", value: "\n\n", line: 1, column: 37}, - {type: "ROUTINE", value: "anweisung", line: 3, column: 1}, - {type: "WHITESPACE", value: " ", line: 3, column: 10}, - {type: "IDENTIFIER", value: "mauer", line: 3, column: 11}, - {type: "LPAREN", value: "(", line: 3, column: 16}, - {type: "RPAREN", value: ")", line: 3, column: 17}, - {type: "WHITESPACE", value: "\n ", line: 3, column: 18}, - {type: "WHILE", value: "solange", line: 4, column: 5}, - {type: "WHITESPACE", value: " ", line: 4, column: 12}, - {type: "NOT", value: "nicht", line: 4, column: 13}, - {type: "WHITESPACE", value: " ", line: 4, column: 18}, - {type: "IDENTIFIER", value: "IstWand", line: 4, column: 19}, - {type: "LPAREN", value: "(", line: 4, column: 26}, - {type: "RPAREN", value: ")", line: 4, column: 27}, - {type: "WHITESPACE", value: " ", line: 4, column: 28}, - {type: "DO", value: "tue", line: 4, column: 29}, - {type: "WHITESPACE", value: "\n ", line: 4, column: 32}, - {type: "IDENTIFIER", value: "hinlegen", line: 5, column: 9}, - {type: "LPAREN", value: "(", line: 5, column: 17}, - {type: "INTEGER", value: "2", line: 5, column: 18}, - {type: "RPAREN", value: ")", line: 5, column: 19}, - {type: "WHITESPACE", value: "\n ", line: 5, column: 20}, - {type: "IDENTIFIER", value: "schritt", line: 6, column: 9}, - {type: "LPAREN", value: "(", line: 6, column: 16}, - {type: "RPAREN", value: ")", line: 6, column: 17}, - {type: "WHITESPACE", value: "\n ", line: 6, column: 18}, + {type: "COMMENT", value: "{ Build a wall around the world }", line: 1, column: 1}, + {type: "WHITESPACE", value: "\n\n", line: 1, column: 34}, + {type: "ROUTINE", value: "routine", line: 3, column: 1}, + {type: "WHITESPACE", value: " ", line: 3, column: 8}, + {type: "IDENTIFIER", value: "wall", line: 3, column: 9}, + {type: "LPAREN", value: "(", line: 3, column: 13}, + {type: "RPAREN", value: ")", line: 3, column: 14}, + {type: "WHITESPACE", value: "\n ", line: 3, column: 15}, + {type: "WHILE", value: "while", line: 4, column: 5}, + {type: "WHITESPACE", value: " ", line: 4, column: 10}, + {type: "NOT", value: "not", line: 4, column: 11}, + {type: "WHITESPACE", value: " ", line: 4, column: 14}, + {type: "IDENTIFIER", value: "isLookingAtEdge", line: 4, column: 15}, + {type: "LPAREN", value: "(", line: 4, column: 30}, + {type: "RPAREN", value: ")", line: 4, column: 31}, + {type: "WHITESPACE", value: " ", line: 4, column: 32}, + {type: "DO", value: "do", line: 4, column: 33}, + {type: "WHITESPACE", value: "\n ", line: 4, column: 35}, + {type: "IDENTIFIER", value: "placeBlock", line: 5, column: 9}, + {type: "LPAREN", value: "(", line: 5, column: 19}, + {type: "INTEGER", value: "2", line: 5, column: 20}, + {type: "RPAREN", value: ")", line: 5, column: 21}, + {type: "WHITESPACE", value: "\n ", line: 5, column: 22}, + {type: "IDENTIFIER", value: "step", line: 6, column: 9}, + {type: "LPAREN", value: "(", line: 6, column: 13}, + {type: "RPAREN", value: ")", line: 6, column: 14}, + {type: "WHITESPACE", value: "\n ", line: 6, column: 15}, {type: "ASTERISC", value: "*", line: 7, column: 5}, - {type: "WHILE", value: "solange", line: 7, column: 6}, - {type: "WHITESPACE", value: "\n", line: 7, column: 13}, + {type: "WHILE", value: "while", line: 7, column: 6}, + {type: "WHITESPACE", value: "\n", line: 7, column: 11}, {type: "ASTERISC", value: "*", line: 8, column: 1}, - {type: "ROUTINE", value: "anweisung", line: 8, column: 2}, - {type: "WHITESPACE", value: "\n\n", line: 8, column: 11}, - {type: "COMMENT", value: "// Treppe", line: 10, column: 1}, + {type: "ROUTINE", value: "routine", line: 8, column: 2}, + {type: "WHITESPACE", value: "\n\n", line: 8, column: 9}, + {type: "COMMENT", value: "// Stairs", line: 10, column: 1}, {type: "WHITESPACE", value: "\n", line: 10, column: 10}, - {type: "IDENTIFIER", value: "hinlegen", line: 11, column: 1}, - {type: "LPAREN", value: "(", line: 11, column: 9}, - {type: "RPAREN", value: ")", line: 11, column: 10}, - {type: "WHITESPACE", value: "\n", line: 11, column: 11}, - {type: "IDENTIFIER", value: "schritt", line: 12, column: 1}, - {type: "LPAREN", value: "(", line: 12, column: 8}, - {type: "RPAREN", value: ")", line: 12, column: 9}, - {type: "WHITESPACE", value: "\n", line: 12, column: 10}, - {type: "IDENTIFIER", value: "hinlegen", line: 13, column: 1}, - {type: "LPAREN", value: "(", line: 13, column: 9}, - {type: "INTEGER", value: "2", line: 13, column: 10}, - {type: "RPAREN", value: ")", line: 13, column: 11}, - {type: "WHITESPACE", value: "\n", line: 13, column: 12}, - {type: "IDENTIFIER", value: "schritt", line: 14, column: 1}, - {type: "LPAREN", value: "(", line: 14, column: 8}, - {type: "RPAREN", value: ")", line: 14, column: 9}, - {type: "WHITESPACE", value: "\n", line: 14, column: 10}, - {type: "IDENTIFIER", value: "rechtsDrehen", line: 15, column: 1}, - {type: "LPAREN", value: "(", line: 15, column: 13}, - {type: "RPAREN", value: ")", line: 15, column: 14}, - {type: "WHITESPACE", value: " ", line: 15, column: 15}, - {type: "IDENTIFIER", value: "rechtsDrehen", line: 15, column: 16}, - {type: "LPAREN", value: "(", line: 15, column: 28}, - {type: "RPAREN", value: ")", line: 15, column: 29}, - {type: "WHITESPACE", value: "\n", line: 15, column: 30}, - {type: "IDENTIFIER", value: "hinlegen", line: 16, column: 1}, - {type: "LPAREN", value: "(", line: 16, column: 9}, - {type: "RPAREN", value: ")", line: 16, column: 10}, - {type: "WHITESPACE", value: "\n", line: 16, column: 11}, - {type: "IDENTIFIER", value: "linksDrehen", line: 17, column: 1}, - {type: "LPAREN", value: "(", line: 17, column: 12}, - {type: "RPAREN", value: ")", line: 17, column: 13}, - {type: "WHITESPACE", value: " ", line: 17, column: 14}, - {type: "IDENTIFIER", value: "linksDrehen", line: 17, column: 15}, - {type: "LPAREN", value: "(", line: 17, column: 26}, - {type: "RPAREN", value: ")", line: 17, column: 27}, - {type: "WHITESPACE", value: "\n\n", line: 17, column: 28}, - {type: "IDENTIFIER", value: "mauer", line: 19, column: 1}, - {type: "LPAREN", value: "(", line: 19, column: 6}, - {type: "RPAREN", value: ")", line: 19, column: 7}, - {type: "WHITESPACE", value: "\n", line: 19, column: 8}, - {type: "IDENTIFIER", value: "linksDrehen", line: 20, column: 1}, - {type: "LPAREN", value: "(", line: 20, column: 12}, - {type: "RPAREN", value: ")", line: 20, column: 13}, - {type: "WHITESPACE", value: "\n", line: 20, column: 14}, - {type: "IDENTIFIER", value: "mauer", line: 21, column: 1}, - {type: "LPAREN", value: "(", line: 21, column: 6}, - {type: "RPAREN", value: ")", line: 21, column: 7}, - {type: "WHITESPACE", value: "\n", line: 21, column: 8}, - {type: "IDENTIFIER", value: "linksDrehen", line: 22, column: 1}, - {type: "LPAREN", value: "(", line: 22, column: 12}, - {type: "RPAREN", value: ")", line: 22, column: 13}, - {type: "WHITESPACE", value: "\n", line: 22, column: 14}, - {type: "IDENTIFIER", value: "mauer", line: 23, column: 1}, - {type: "LPAREN", value: "(", line: 23, column: 6}, - {type: "RPAREN", value: ")", line: 23, column: 7}, - {type: "WHITESPACE", value: "\n", line: 23, column: 8}, - {type: "IDENTIFIER", value: "linksDrehen", line: 24, column: 1}, - {type: "LPAREN", value: "(", line: 24, column: 12}, - {type: "RPAREN", value: ")", line: 24, column: 13}, - {type: "WHITESPACE", value: "\n", line: 24, column: 14}, - {type: "IDENTIFIER", value: "mauer", line: 25, column: 1}, - {type: "LPAREN", value: "(", line: 25, column: 6}, - {type: "RPAREN", value: ")", line: 25, column: 7}, - {type: "WHITESPACE", value: "\n", line: 25, column: 8}, - {type: "IDENTIFIER", value: "linksDrehen", line: 26, column: 1}, - {type: "LPAREN", value: "(", line: 26, column: 12}, - {type: "RPAREN", value: ")", line: 26, column: 13}, - {type: "WHITESPACE", value: "\n", line: 26, column: 14}, + {type: "IDENTIFIER", value: "placeBlock", line: 11, column: 1}, + {type: "LPAREN", value: "(", line: 11, column: 11}, + {type: "RPAREN", value: ")", line: 11, column: 12}, + {type: "WHITESPACE", value: "\n", line: 11, column: 13}, + {type: "IDENTIFIER", value: "step", line: 12, column: 1}, + {type: "LPAREN", value: "(", line: 12, column: 5}, + {type: "RPAREN", value: ")", line: 12, column: 6}, + {type: "WHITESPACE", value: "\n", line: 12, column: 7}, + {type: "IDENTIFIER", value: "placeBlock", line: 13, column: 1}, + {type: "LPAREN", value: "(", line: 13, column: 11}, + {type: "INTEGER", value: "2", line: 13, column: 12}, + {type: "RPAREN", value: ")", line: 13, column: 13}, + {type: "WHITESPACE", value: "\n", line: 13, column: 14}, + {type: "IDENTIFIER", value: "step", line: 14, column: 1}, + {type: "LPAREN", value: "(", line: 14, column: 5}, + {type: "RPAREN", value: ")", line: 14, column: 6}, + {type: "WHITESPACE", value: "\n", line: 14, column: 7}, + {type: "IDENTIFIER", value: "turnRight", line: 15, column: 1}, + {type: "LPAREN", value: "(", line: 15, column: 10}, + {type: "RPAREN", value: ")", line: 15, column: 11}, + {type: "WHITESPACE", value: " ", line: 15, column: 12}, + {type: "IDENTIFIER", value: "turnRight", line: 15, column: 13}, + {type: "LPAREN", value: "(", line: 15, column: 22}, + {type: "RPAREN", value: ")", line: 15, column: 23}, + {type: "WHITESPACE", value: "\n", line: 15, column: 24}, + {type: "IDENTIFIER", value: "placeBlock", line: 16, column: 1}, + {type: "LPAREN", value: "(", line: 16, column: 11}, + {type: "RPAREN", value: ")", line: 16, column: 12}, + {type: "WHITESPACE", value: "\n", line: 16, column: 13}, + {type: "IDENTIFIER", value: "turnLeft", line: 17, column: 1}, + {type: "LPAREN", value: "(", line: 17, column: 9}, + {type: "RPAREN", value: ")", line: 17, column: 10}, + {type: "WHITESPACE", value: " ", line: 17, column: 11}, + {type: "IDENTIFIER", value: "turnLeft", line: 17, column: 12}, + {type: "LPAREN", value: "(", line: 17, column: 20}, + {type: "RPAREN", value: ")", line: 17, column: 21}, + {type: "WHITESPACE", value: "\n\n", line: 17, column: 22}, + {type: "IDENTIFIER", value: "wall", line: 19, column: 1}, + {type: "LPAREN", value: "(", line: 19, column: 5}, + {type: "RPAREN", value: ")", line: 19, column: 6}, + {type: "WHITESPACE", value: "\n", line: 19, column: 7}, + {type: "IDENTIFIER", value: "turnLeft", line: 20, column: 1}, + {type: "LPAREN", value: "(", line: 20, column: 9}, + {type: "RPAREN", value: ")", line: 20, column: 10}, + {type: "WHITESPACE", value: "\n", line: 20, column: 11}, + {type: "IDENTIFIER", value: "wall", line: 21, column: 1}, + {type: "LPAREN", value: "(", line: 21, column: 5}, + {type: "RPAREN", value: ")", line: 21, column: 6}, + {type: "WHITESPACE", value: "\n", line: 21, column: 7}, + {type: "IDENTIFIER", value: "turnLeft", line: 22, column: 1}, + {type: "LPAREN", value: "(", line: 22, column: 9}, + {type: "RPAREN", value: ")", line: 22, column: 10}, + {type: "WHITESPACE", value: "\n", line: 22, column: 11}, + {type: "IDENTIFIER", value: "wall", line: 23, column: 1}, + {type: "LPAREN", value: "(", line: 23, column: 5}, + {type: "RPAREN", value: ")", line: 23, column: 6}, + {type: "WHITESPACE", value: "\n", line: 23, column: 7}, + {type: "IDENTIFIER", value: "turnLeft", line: 24, column: 1}, + {type: "LPAREN", value: "(", line: 24, column: 9}, + {type: "RPAREN", value: ")", line: 24, column: 10}, + {type: "WHITESPACE", value: "\n", line: 24, column: 11}, + {type: "IDENTIFIER", value: "wall", line: 25, column: 1}, + {type: "LPAREN", value: "(", line: 25, column: 5}, + {type: "RPAREN", value: ")", line: 25, column: 6}, + {type: "WHITESPACE", value: "\n", line: 25, column: 7}, + {type: "IDENTIFIER", value: "turnLeft", line: 26, column: 1}, + {type: "LPAREN", value: "(", line: 26, column: 9}, + {type: "RPAREN", value: ")", line: 26, column: 10}, + {type: "WHITESPACE", value: "\n", line: 26, column: 11}, ] diff --git a/src/language/tokens.ts b/src/language/tokens.ts index 39da4da..f270a90 100644 --- a/src/language/tokens.ts +++ b/src/language/tokens.ts @@ -1,4 +1,5 @@ -import {Exception} from "../localization" +import {Exception} from "../exception" + // Token types export const IDENTIFIER = "IDENTIFIER" as const @@ -48,30 +49,7 @@ export type TokenType = | "SINGLEQUOTE" | "DOUBLEQUOTE" | "WHITESPACE" | "COMMENT" | "EOF" -const keywordTokenTypes: Record = { - wenn: IF, - if: IF, - dann: THEN, - then: THEN, - sonst: ELSE, - else: ELSE, - solange: WHILE, - while: WHILE, - tue: DO, - do: DO, - nicht: NOT, - not: NOT, - wiederhole: REPEAT, - repeat: REPEAT, - mal: TIMES, - times: TIMES, - programm: PROGRAM, - program: PROGRAM, - anweisung: ROUTINE, - routine: ROUTINE, -} - -const symbolTokenTypes: Record = { +const symbolToTokenType = new Map(Object.entries({ "(": LPAREN, ")": RPAREN, "[": LBRACKET, @@ -91,8 +69,43 @@ const symbolTokenTypes: Record = { ";": SEMI, "'": SINGLEQUOTE, '"': DOUBLEQUOTE, +})) + +export const keywordTokenTypes = [ + IF, THEN, ELSE, + REPEAT, WHILE, DO, + NOT, TIMES, + PROGRAM, ROUTINE, +] as const + +const keywordToTokenType = new Map(Object.entries({ + if: IF, + then: THEN, + else: ELSE, + while: WHILE, + do: DO, + not: NOT, + repeat: REPEAT, + times: TIMES, + program: PROGRAM, + routine: ROUTINE, +})) + +export const tokenTypeToLiteral = new Map( + [...symbolToTokenType, ...keywordToTokenType].map(([literal, tokenType]) => [tokenType, literal]), +) + +export function setKeywords(keywords: Map) { + keywordToTokenType.clear() + tokenTypeToLiteral.clear() + symbolToTokenType.forEach((tokenType, symbol) => tokenTypeToLiteral.set(tokenType, symbol)) + for (const [keyword, tokenType] of keywords) { + keywordToTokenType.set(keyword, tokenType) + tokenTypeToLiteral.set(tokenType, keyword) + } } + export interface Token { type: TokenType value: string @@ -133,7 +146,7 @@ export function* tokenize( reWord.lastIndex = position if (match = reWord.exec(text)) { value = match[0] - yield {type: keywordTokenTypes[value.toLowerCase()] || IDENTIFIER, + yield {type: keywordToTokenType.get(value.toLowerCase()) || IDENTIFIER, value, line, column} column += value.length position += value.length @@ -190,8 +203,8 @@ export function* tokenize( // special character (must be checked after // comments) value = text[position] - if (symbolTokenTypes.hasOwnProperty(value)) { - yield {type: symbolTokenTypes[value], value, line, column} + if (symbolToTokenType.has(value)) { + yield {type: symbolToTokenType.get(value) as TokenType, value, line, column} ++column ++position continue diff --git a/src/localization.ts b/src/localization.ts index 92bb62b..77e8c8b 100644 --- a/src/localization.ts +++ b/src/localization.ts @@ -1,40 +1,37 @@ import * as config from "./config" import {flattenKeys} from "./util" +import {keywordTokenTypes, setKeywords} from "./language/tokens" -// const DATA_ATTRIBUTE_SUFFIX = "i18t" const INTERPOLATION_REGEX = /\{([^}]*?)\}/g -let translations: Record +const BROWSER_LOCALE = navigator.language.split(/[_-]/)[0] + + +let translations: Map async function setLocales(locales: string[]) { - locales = Array.isArray(locales) ? locales : [locales] - if (locales.includes("auto")) { - const browserLocale = navigator.language.split(/[_-]/)[0] - locales = locales.map(locale => locale === "auto" ? browserLocale : locale) - } - locales = Array.from(new Set(locales.filter(Boolean))) - translations = Object.assign(Object.create(null), ...await Promise.all( - locales - .map(l => config.get(`localization/${l}.js`).then(flattenKeys)) - .reverse(), - )) + const promises = Array.from(new Set(Array.isArray(locales) ? locales : [locales])) + .filter(Boolean) + .reverse() + .map(locale => locale.replace(/auto(?=-|$)/, BROWSER_LOCALE)) + .map(locale => config.get(`localization/${locale}.js`)) + const definitions = await Promise.all(promises) + translations = new Map(definitions.map(flattenKeys).flatMap(Object.entries)) } + /** Translate a language variable according to the configured locale. */ export function translate(variable: string, ...values: any[]) { - if (!(variable in translations)) { + if (!translations.has(variable)) { console.warn(`Could not resolve localization variable ${variable}`) return variable } - const result = translations[variable] - if (typeof result === "string") { - return values.length ? interpolate(result, values) : result - } else { - return JSON.stringify(result) - } + const result = translations.get(variable) as string + return values.length ? interpolate(result, values) : result } + /** Subsitute values into a string. Inspired by Python's str.format method. */ function interpolate(string: string, values: any[]) { if (values.length === 1 && typeof values[0] === "object") { @@ -47,23 +44,13 @@ function interpolate(string: string, values: any[]) { ) } -/** - * Errors for our programming environment. - * These do not inherit from JS's own Error as they do not need - * to reveal details of the interpreter/runtime internals. - * - * Offers translation & interpolation. - */ -export class Exception { - message: string - data: any[] - constructor(message: string, ...data: any[]) { - this.message = message - this.data = data - } - get translatedMessage(): string { - return translate(this.message, ...this.data) - } -} -export const init = () => config.get().then(cfg => setLocales(cfg.locale)) +/** Load translations */ +export async function init() { + const {locale} = await config.get() + await setLocales(locale) + + setKeywords(new Map( + keywordTokenTypes.map(tt => [translate(`language.${tt}`), tt]), + )) +} diff --git a/src/simulation/simulation.ts b/src/simulation/simulation.ts index f57b719..a8233ca 100644 --- a/src/simulation/simulation.ts +++ b/src/simulation/simulation.ts @@ -4,6 +4,17 @@ import type {World, WorldInteraction} from "./world" import {noop} from "../util" const commandNames: Record = { + // TODO set from localization + ...Object.fromEntries([ + "turnLeft", "turnRight", + "isLookingAtEdge", "isNotLookingAtEdge", + "step", "stepBackwards", + "isLookingAtBlock", "isNotLookingAtBlock", + "placeBlock", "takeBlock", + "isOnMark", "isNotOnMark", + "placeMark", "takeMark", + ].map(command => [command.toLowerCase(), command])), + linksdrehen: "turnLeft", rechtsdrehen: "turnRight", diff --git a/src/simulation/world.ts b/src/simulation/world.ts index 3e22b5d..f3846a7 100644 --- a/src/simulation/world.ts +++ b/src/simulation/world.ts @@ -1,5 +1,5 @@ import {rand} from "../util" -import {Exception} from "../localization" +import {Exception} from "../exception" export interface WorldInteraction { isLookingAtEdge: () => boolean diff --git a/src/ui/Editor.tsx b/src/ui/Editor.tsx index 649f7a1..c254e9a 100644 --- a/src/ui/Editor.tsx +++ b/src/ui/Editor.tsx @@ -257,11 +257,13 @@ const Highlight = ({children, marks}: {children: string, marks?: Marks}) => { } -// Load editor theme css -export const loadTheme = () => config.get() - .then(({editor_theme}) => document.head.append( +/** Load editor theme css */ +export async function init() { + const {editor_theme} = await config.get() + document.head.append( elem("link", { rel: "stylesheet", href: `themes/editor-${editor_theme || "bright"}.css`, }), - )) + ) +} diff --git a/src/ui/Logging.tsx b/src/ui/Logging.tsx index 1e99663..5d49fe4 100644 --- a/src/ui/Logging.tsx +++ b/src/ui/Logging.tsx @@ -1,7 +1,8 @@ import {ComponentChild, ComponentChildren, createContext, h} from "preact" import {StateUpdater, useContext, useState} from "preact/hooks" -import {Exception, translate as t} from "../localization" +import {Exception} from "../exception" +import {translate as t} from "../localization" import {noop} from "../util" import style from "./Logging.module.css" @@ -75,7 +76,10 @@ export const LogOutput = () => { ref={p => idx === messages.length - 1 && p?.scrollIntoView({behavior: "smooth"})} class={style[level]} > - {message ? t(...message) : exception ? exception.translatedMessage : child} + {message + ? t(...message) + : exception ? t(exception.message, ...exception.data) : child + }

, )} diff --git a/src/ui/Main.tsx b/src/ui/Main.tsx index 9ef7b06..0210dff 100644 --- a/src/ui/Main.tsx +++ b/src/ui/Main.tsx @@ -1,7 +1,8 @@ import {Fragment, h} from "preact" import {useContext, useEffect, useState} from "preact/hooks" -import {Exception, translate as t} from "../localization" +import {Exception} from "../exception" +import {translate as t} from "../localization" import {run} from "../simulation/simulation" import {World} from "../simulation/world" import {Logging} from "./Logging" diff --git a/src/ui/WorldControls.tsx b/src/ui/WorldControls.tsx index d5125d4..cabb412 100644 --- a/src/ui/WorldControls.tsx +++ b/src/ui/WorldControls.tsx @@ -2,7 +2,8 @@ import {Fragment, h} from "preact" import {useCallback, useContext, useEffect} from "preact/hooks" import {render} from "../graphics" -import {Exception, translate as t} from "../localization" +import {Exception} from "../exception" +import {translate as t} from "../localization" import type {World, WorldInteraction} from "../simulation/world" import {Logging} from "./Logging" import {IconMinusCircle, IconPlay, IconPlusCircle, IconReply} from "./Icon" @@ -45,7 +46,7 @@ export const WorldControls = ({world, disabled}: WorldControlsProps) => { render(world) } catch (err) { if (err instanceof Exception) { - log.error(err.translatedMessage) + log.error(err) } else { log.error(err.message) console.error(err) diff --git a/src/util/index.ts b/src/util/index.ts index 01b5497..61848e6 100644 --- a/src/util/index.ts +++ b/src/util/index.ts @@ -59,6 +59,13 @@ export const flattenKeys = (obj: Record) => { } +/** Join items with commas and a final word, e.g. "1, 2 and 3" */ +export const commaList = (xs: any[], finalSeparator = " and ") => + xs.length < 3 + ? xs.join(finalSeparator) + : xs.slice(0, -1).join(", ") + finalSeparator + xs[xs.length - 1] + + /** Calculate sum of numbers in an array */ export const sum = (xs: number[]) => xs.reduce((x, acc) => x + acc, 0)