← Zurück zur Hilfe

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:

DateiInhalt
src/i18n/i18n.tsSchlüsselwörter und alle Meldungen, je Sprache 118 Einträge
src/world/world.tsNamen der Weltbefehle, alle Sprachen in einer Tabelle
src/commandHelp.tsBefehlsliste, Kurzbeschreibungen, Beispiele
src/website.tsTexte der Oberfläche
src/help.tsTexte der Hilfeseite
src/utils/flaggen.tsFlaggen 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:

  1. build-version.js schreibt die Versionsnummer aus package.json nach src/version.ts
  2. tsc übersetzt TypeScript nach dist/, für die REPL
  3. build-examples-index.js erzeugt examples/index.json, weil ein Browser keine Verzeichnisse auflisten kann
  4. esbuild bündelt drei Dateien für den Browser: bundle.js, worker.js, help.js
  5. build-docs-html.js macht aus Tutorial und Referenz lesbare HTML-Seiten
  6. copy-config legt 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

BereichZeilen
Oberfläche (website.ts)rund 2400
Welt (world.ts)rund 1300
Übersetzungen (i18n.ts)rund 1150
Parserrund 1040
REPLrund 770
Interpreterrund 720
Lexerrund 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.

AbschnittWas darin steckt
einstellungen, weltkonfiguration, beispielliste, weltenlistedie Dateien, die beim Laden der Seite geholt werden
seiteBereitvom Beginn des Aufbaus bis zur bedienbaren Oberfläche
workerVorlaufdas Anlegen des Rechenstrangs in der ersten Ruhepause
vorbereitungZähler, Markierungen, alte Bewegung abräumen
workerStartden Rechenstrang holen, falls es ihn noch nicht gibt
weltAbgebenden Weltzustand für die Übergabe einpacken
uebergabebis die Nachricht im Rechenstrang ankommt
zerlegenLexer und Parser
ausfuehrender Interpreter selbst
zustandAbgeben, ruecksendungder 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

eintragen, in docs/lny-referenz.md beschreiben, Test schreiben. Ein reiner Weltbefehl braucht den Parser nicht anzufassen.

und die passende Stelle im Parser.

buildWorld, die Anzeige in website.ts, die Konfiguration im Weltloader. Wasser und Spielplatz sind gute Vorlagen.

↑