Wie lny funktioniert
Ein Blick unter die Haube: Was passiert zwischen dem Klick auf Start und Lennys erstem Schritt? Dieser Text richtet sich an alle, die den Quelltext lesen oder etwas daran ändern wollen. Sprachkenntnisse in TypeScript helfen, sind aber für die ersten Abschnitte nicht nötig.
Der Weg eines Programms
Ein lny-Programm durchläuft vier Stationen. Jede hat eine klar umrissene Aufgabe und weiß möglichst wenig von den anderen.
Quelltext → Lexer → Parser → Interpreter → Welt
"LAUF 3" Token Syntaxbaum Ausführung Lenny bewegt sich
Nehmen wir dieses kleine Programm:
WIEDERHOLE 2 MAL
LAUF 3
ENDE
1. Der Lexer zerlegt den Text
src/lexer/lexer.ts liest den Quelltext Zeichen für Zeichen und macht daraus eine Liste von Token, also von kleinsten bedeutungstragenden Einheiten:
REPEAT("WIEDERHOLE") NUMBER("2") COUNT("MAL") NEWLINE
IDENTIFIER("LAUF") NUMBER("3") NEWLINE END("ENDE")
Zwei Dinge fallen auf. Erstens trägt jedes Token seine Zeile und Spalte mit sich; daher können Fehlermeldungen später sagen, wo etwas nicht stimmt. Zweitens heißt der Tokentyp REPEAT, obwohl im Text WIEDERHOLE steht: Die Tokentypen sind sprachunabhängig. Genau hier findet die Mehrsprachigkeit statt. Der Lexer baut beim Start eine Tabelle aus src/i18n/i18n.ts, die jedes Schlüsselwort der eingestellten Sprache auf seinen Tokentyp abbildet. Für Französisch steht dann REPETE in derselben Zeile der Tabelle.
Groß- und Kleinschreibung spielt keine Rolle, und Akzente werden beim Vergleich entfernt. Deshalb funktionieren SKRĘĆ_LEWO und SKREC_LEWO gleichermaßen.
2. Der Parser baut einen Baum
src/parser/parser.ts liest die Token und prüft, ob sie eine sinnvolle Struktur ergeben. Das Ergebnis ist ein Syntaxbaum, in dem die Verschachtelung sichtbar wird:
Program
RepeatStatement (Zeile 1)
count: NumberLiteral 2
body:
ExpressionStatement (Zeile 2)
CallExpression LAUF
arguments: [NumberLiteral 3]
Der Parser arbeitet nach dem Verfahren des rekursiven Abstiegs: Für jede Art von Befehl gibt es eine Funktion, die sich bei Bedarf selbst wieder aufruft. Ein WIEDERHOLE liest seine Anzahl und ruft dann dieselbe Funktion auf, die auch das Hauptprogramm liest, bis ein ENDE kommt.
Fast alle Fehlermeldungen entstehen hier. Fehlt ein ENDE, merkt der Parser, dass der Block nie geschlossen wurde, und nennt Blockart und Zeile des Anfangs. Steht ein Befehl einer anderen Sprache im Text, schlägt er in den Tabellen der übrigen Sprachen nach und schreibt dazu, wie der Befehl hier heißt.
Die Knoten des Baums sind in src/ast/ast.ts beschrieben: schlichte Objekte mit einem type, den nötigen Feldern und, wo vorhanden, Zeile und Spalte.
3. Der Interpreter führt den Baum aus
src/interpreter/interpreter.ts geht den Baum durch und tut, was dort steht. Es gibt keinen Zwischenschritt über Bytecode; der Baum selbst ist das Programm.
Zwei Methoden tragen die Hauptlast: executeStatement für Befehle und evaluate für Ausdrücke. Variablen leben in einer Umgebung (LnyEnvironment), die Namen auf Werte abbildet. Prozeduren bekommen eine eigene Umgebung, die auf die umgebende verweist, sodass sie äußere Variablen lesen können.
Abbruch und Rückgabe sind als Ausnahmen umgesetzt: ABBRUCH wirft ein Signal, das die nächste Schleife abfängt, RUECKGABE eines, das der Prozeduraufruf abfängt. Das erspart es, jeden Befehl einen Statuswert zurückgeben zu lassen.
Eine Zeitgrenze bewacht die Ausführung. Alle paar Befehle prüft der Interpreter, wie lange er schon läuft, und bricht ab, wenn es zu lange dauert. Ohne diese Bremse würde eine Schleife ohne Ende den Browser einfrieren.
4. Die Welt hält den Zustand
src/world/world.ts ist alles, was nicht Sprache ist: das Spielfeld, Lennys Position und Blickrichtung, die Knochen, das Wasser, die Spielplätze. Die Weltbefehle sind hier als Tabelle eingebauter Funktionen abgelegt, einmal je Sprachvariante des Namens:
for (const name of ['run', 'lauf', 'marche', 'corre', 'corri', 'loop', 'idz', 'ydi', 'pigaine']) {
this.builtIns.set(name, (args) => { this.builtin_run(args); return null; });
}
Anders als bei den Schlüsselwörtern liegen hier alle Sprachen in einer einzigen Tabelle. Das ist bewusst so: Wer LAUF in einem englischen Programm schreibt, bekommt keinen Fehler, sondern das erwartete Verhalten.
Jede Bewegung meldet ein Ereignis an die Oberfläche: gelaufen, gedreht, gesprungen, genommen, gegeben, getrunken, gespielt. Daraus entstehen die Animation in der Weltansicht und die Klänge.
Die Oberfläche
src/website.ts ist mit Abstand die größte Datei, weil dort alles zusammenläuft: Editor, Ausgabe, Weltansicht, Seitenleiste, Sprachumschaltung, Beispiele, Laden und Speichern, langsamer Modus.
Ein Programm läuft normalerweise in einem Web Worker (src/worker.ts), also in einem eigenen Strang. Dadurch bleibt die Seite bedienbar, und der Stopp-Knopf kann eine Rechnung wirklich abbrechen. Der Worker bekommt Code, Sprache, Weltzustand und Weltregeln, schickt Ausgabe und Endzustand zurück.
Zwei Fälle laufen absichtlich ohne Worker: der langsame Modus, weil dort nach jedem einzelnen Befehl gemessen wird, und der Rückfallweg für Browser ohne Worker-Unterstützung.
Die Bewegung sieht man nicht live. Das Programm läuft zu Ende, dabei werden die Ereignisse samt Weltzustand aufgezeichnet, und danach spielt die Oberfläche sie im Takt von step_delay ab. Das entkoppelt Rechnen und Zeigen: Ein Programm mit tausend Schritten rechnet in Millisekunden und wird trotzdem in Ruhe vorgeführt.
Mehrsprachigkeit an einer Stelle
Alles Sprachabhängige steht in wenigen Dateien:
| Datei | Inhalt |
|---|---|
src/i18n/i18n.ts | Schlüsselwörter und alle Meldungen, je Sprache 118 Einträge |
src/world/world.ts | Namen der Weltbefehle, alle Sprachen in einer Tabelle |
src/commandHelp.ts | Befehlsliste, Kurzbeschreibungen, Beispiele |
src/website.ts | Texte der Oberfläche |
src/help.ts | Texte der Hilfeseite |
src/utils/flaggen.ts | Flaggen und Sprachnamen |
Eine neue Sprache anzulegen heißt: in diesen sechs Dateien je einen Block ergänzen und Language um das Kürzel erweitern. TypeScript führt dabei Buch, denn die Tabellen sind als Record<Language, ...> beschrieben; solange ein Block fehlt, lässt sich das Projekt nicht übersetzen.
Beim Sprachwechsel in der Oberfläche wird auch das Programm im Editor übersetzt (src/utils/localizeScript.ts). Dabei werden nur Wörter ersetzt, die als Befehl bekannt sind; Zeichenketten und eigene Namen bleiben unangetastet.
Der Bauvorgang
npm run build
Dahinter stecken mehrere Schritte:
build-version.jsschreibt die Versionsnummer auspackage.jsonnachsrc/version.tstscübersetzt TypeScript nachdist/, für die REPLbuild-examples-index.jserzeugtexamples/index.json, weil ein Browser keine Verzeichnisse auflisten kannesbuildbündelt drei Dateien für den Browser:bundle.js,worker.js,help.jsbuild-docs-html.jsmacht aus Tutorial und Referenz lesbare HTML-Seitencopy-configlegt die Weltkonfiguration neben die REPL
Ein Makefile fasst das zusammen: make, make test, make check, make web.
Tests
Rund 250 Tests in tests/ laufen mit Jest. Sie sind nach Bereichen geordnet: Lexer und Parser, Interpreter, Weltereignisse, Weltkonfiguration, Übersetzungen, Befehlshilfe, Beispieldateien, dazu die versteckten Dinge.
Zwei Sorten Test sind besonders nützlich. Die einen führen alle mitgelieferten Beispiele aus und schlagen fehl, sobald eines nicht mehr läuft. Die anderen prüfen die Vollständigkeit der Übersetzungen: Jede Sprache muss dieselbe Anzahl Befehle und Beschreibungen haben. Beide Sorten haben mehr Fehler gefunden als alle handgeschriebenen Einzelfälle zusammen.
Größenordnungen
| Bereich | Zeilen |
|---|---|
Oberfläche (website.ts) | rund 2400 |
Welt (world.ts) | rund 1300 |
Übersetzungen (i18n.ts) | rund 1150 |
| Parser | rund 1040 |
| REPL | rund 770 |
| Interpreter | rund 720 |
| Lexer | rund 560 |
Dass die Oberfläche größer ist als Lexer, Parser und Interpreter zusammen, ist kein Versehen. Die Sprache selbst ist klein; der Aufwand steckt darin, sie begreifbar zu machen.
Nachmessen, wenn etwas hakt
Manchmal läuft ein Programm nicht sofort los. Statt zu raten, misst lny mit: jeder Lauf wird in Abschnitte zerlegt, und jeder Abschnitt bekommt seine Zahl.
Die Messung hängt am Fenster und ist in der Konsole des Browsers zu lesen. Es wird nichts gespeichert und nichts übertragen; schließt man die Seite, sind die Zahlen weg.
lny.letzterLauf() // der letzte Lauf mit allen Abschnitten
lny.bericht() // dazu die Startphasen und Kennzahlen über alle Läufe
lny.json() // dasselbe als Text zum Weitergeben
lny.zuruecksetzen() // die aufbewahrten Läufe löschen
Mit ?diagnose=1 in der Adresse erscheint zusätzlich ein kleines Feld unten rechts, das die Zahlen nach jedem Lauf zeigt, den dicksten Posten hervorhebt und den Bericht auf Knopfdruck in die Zwischenablage legt. Nur in diesem Betrieb wird außerdem jede einzelne Anweisung gemessen, denn das kostet bei jeder Anweisung einen Rückruf.
| Abschnitt | Was darin steckt |
|---|---|
einstellungen, weltkonfiguration, beispielliste, weltenliste | die Dateien, die beim Laden der Seite geholt werden |
seiteBereit | vom Beginn des Aufbaus bis zur bedienbaren Oberfläche |
workerVorlauf | das Anlegen des Rechenstrangs in der ersten Ruhepause |
vorbereitung | Zähler, Markierungen, alte Bewegung abräumen |
workerStart | den Rechenstrang holen, falls es ihn noch nicht gibt |
weltAbgeben | den Weltzustand für die Übergabe einpacken |
uebergabe | bis die Nachricht im Rechenstrang ankommt |
zerlegen | Lexer und Parser |
ausfuehren | der Interpreter selbst |
zustandAbgeben, ruecksendung | der Weg der Ergebnisse zurück |
Die Bewegung in der Weltansicht wird bewusst nicht mitgezählt. Sie dauert so lange, wie der eingestellte Takt es vorgibt, und sagt über die Geschwindigkeit des Interpreters nichts aus.
Zwei Dinge hat die Messung schon zutage gefördert. Erstens kostete der erste Lauf einer Sitzung ein Vielfaches der folgenden, weil der Browser die Nachricht erst ausführt, wenn worker.js geladen ist; seitdem wird der Rechenstrang in der ersten Ruhepause nach dem Laden angelegt. Zweitens wächst die Rücksendung mit der Zahl der aufgezeichneten Zwischenstände, weil jeder davon die ganze Welt mitbringt - bei dreihundert Schritten dauert der Rückweg länger als das Rechnen.
Wo man am besten anfängt
- Einen Befehl hinzufügen: in
world.tsregistrieren, incommandHelp.ts
eintragen, in docs/lny-referenz.md beschreiben, Test schreiben. Ein reiner Weltbefehl braucht den Parser nicht anzufassen.
- Ein Schlüsselwort hinzufügen: zusätzlich
i18n.ts, den Tokentyp im Lexer
und die passende Stelle im Parser.
- Die Welt erweitern: ein Feld in
TerrainField, die Erzeugung in
buildWorld, die Anzeige in website.ts, die Konfiguration im Weltloader. Wasser und Spielplatz sind gute Vorlagen.