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)