Zum Inhalt

Jade Compiler — CompilerDb & Query System

Offizielle Subsystem API Spezifikation

Status: Normativ
Version: 0.5.0
Scope: Bootstrap-Compiler (D), alle Compiler-Subsysteme
Priorität: Bei Widerspruch mit anderen Dokumenten gilt diese Spec für Compiler-Interna.


Inhaltsverzeichnis

  1. Motivation und Designziele
  2. Kernkonzepte
  3. Query — Definition und Anatomie
  4. CompilerDb — die zentrale Datenbank
  5. Subsystem-Registrierung
  6. Dependency-Tracking
  7. Lazy Revalidierung — Early Cutoff
  8. Durability
  9. Cancellation
  10. Arena-Integration
  11. Fehlerbehandlung
  12. Emergente Tools — Consumer der Query API
  13. jade doc — Query-basierter Dokumentationsgenerator
  14. Vollständiges Beispiel — End-to-End
  15. Query-Katalog — Alle definierten Queries
  16. Erweiterung — Neue Queries hinzufügen
  17. Invarianten und Regeln
  18. Implementierungshinweise

1. Motivation und Designziele

1.1 Das Problem

Ein Compiler besteht aus mehreren Subsystemen — Parser, TypeEngine, NameResolver, JME — die alle Informationen voneinander benötigen. Ohne ein strukturiertes System entstehen folgende Probleme:

  • Direkte Kopplungen: Subsystem A kennt die interne Struktur von Subsystem B. Refactoring in B bricht A.
  • Keine Inkrementalität: Bei jeder Änderung wird alles neu berechnet. Für LSP-Feedback in 50ms ist das untragbar.
  • Inkonsistente APIs: Jedes Subsystem hat eine andere Aufrufsyntax, andere Fehlerbehandlung, andere Konventionen.
  • Kein Caching: Dieselbe Berechnung wird mehrfach ausgeführt.

1.2 Die Lösung: Query-System

Alle Subsystem-Kommunikation läuft über eine einzige, einheitliche Schnittstelle:

auto result = db.query(SomeQuery { ...params });

Das Query-System löst alle vier Probleme gleichzeitig:

  • Entkopplung: Subsysteme kennen sich nicht — sie kennen nur die DB.
  • Inkrementalität: Nur was sich geändert hat wird neu berechnet.
  • Einheitliche API: Jede Abfrage sieht identisch aus.
  • Automatisches Caching: Jedes Ergebnis wird automatisch gecacht.

1.3 Designziele

Ziel Beschreibung
Einheitlichkeit Eine API für alle Subsystem-Kommunikation
Erweiterbarkeit Neue Query = neues struct + Handler-Funktion. Kein Dispatch-Eintrag nötig.
@nogc Keine GC-Allokationen auf dem Hot Path
pure nothrow Alle Derived-Handler sind weakly pure und nothrow — der Compiler erzwingt das statisch
Inkrementalität Inkrementelle Neuberechnung ist keine Erweiterung — es ist das Kerndesign
Testbarkeit Jede Query isoliert testbar mit Mock-DB
Debuggbarkeit Dep-Graph ist zur Laufzeit inspektierbar

1.4 Philosophische Konsistenz

Das Query-System ist dieselbe Architektur wie JDL selbst:

JDL:         service UserDb { ... }     → Subsystem
             CallGraph { requires: [] } → zentrales Wiring
             Effect[R, E, D]            → typisierte Abfrage

Compiler:    struct TypeEngine { ... }  → Subsystem
             CompilerDb { ... }         → zentrales Wiring
             Query { ... }              → typisierte Abfrage

Der Compiler lebt die Philosophie die er kompiliert.


2. Kernkonzepte

2.1 Query

Eine Query ist eine typisierte, idempotente Anfrage an die CompilerDb. Sie beschreibt was man wissen will, nicht wie es berechnet wird.

// Eine Query beschreibt die Anfrage:
struct ResolveType {
    TypeId id;
    // Rückgabetyp: TypeNode
}

Eigenschaften einer korrekten Query: - Deterministisch: gleiche Eingabe → gleiche Ausgabe - Seiteneffektfrei: keine Mutation von globalem Zustand - Idempotent: mehrfacher Aufruf ist identisch zu einmaligem Aufruf

2.2 CompilerDb

Die CompilerDb ist die einzige Kommunikationsschicht zwischen Subsystemen. Kein Subsystem darf direkt auf ein anderes zugreifen.

Parser    TypeEngine    Resolver    JME
   ↓           ↓           ↓        ↓
   └───────────┴───────────┴────────┘
                    CompilerDb
                 (Cache + DepGraph)

2.3 Dependency-Graph

Wenn Query A ausgeführt wird und dabei Query B aufruft, weiß die DB: "A hängt von B ab." Ändert sich B, wird A beim nächsten Zugriff revalidiert.

2.4 Cache

Jedes Query-Ergebnis wird gecacht. Identische Queries (gleiche Parameter) werden nie zweimal berechnet — außer wenn Lazy Revalidierung eine Neuberechnung ergibt (siehe Abschnitt 7).

2.5 Input Queries vs. Derived Queries

Dies ist die fundamentale Zweiteilung des Query-Systems:

Input Queries — Werte die von außen gesetzt werden. Sie haben keinen @handles-Handler. Schreiben erfolgt über die setInput-API. Jedes setInput erhöht die globale currentRevision.

// Input Queries — kein @handles, werden gesetzt nicht berechnet:
struct FileContent { string path; }   // → string (Dateiinhalt)
struct BuildConfig { }                // → Config
struct ModuleList  { }                // → string[] (bekannte Dateien)

Derived Queries — Werte die aus anderen Queries (Inputs oder anderen Derived) berechnet werden. Sie haben einen @handles-Handler. Sie sind pure nothrow @nogc. Sie werden lazy und on-demand berechnet.

// Derived Query — hat @handles-Handler, berechnet aus anderen Queries:
@handles!GetFileAST
AST parseFile(GetFileAST q, CompilerDb* db) @nogc nothrow pure {
    // Liest FileContent-Input — Dependency wird automatisch getrackt:
    auto source = db.query!string(FileContent { q.path });
    return db.parser.parseFromString(source);   // pure Berechnung
}

Die pure-Invariante (Invariante 3) gilt nur für Derived Queries. Input Queries haben keine Handler und unterliegen nicht dieser Regel.


3. Query — Definition und Anatomie

3.1 Eine Query definieren

// Minimale Derived Query — Input-Struct + @handles-Handler im Subsystem:
struct ResolveType {
    TypeId id;                  // Input-Parameter
    // Impliziter Output-Typ: TypeNode (über Handler-Signatur)
}

Eine Query ist immer ein struct. Felder = Parameter. Keine Methoden.

3.2 Query-Schlüssel (QueryKey)

Jede Query braucht einen eindeutigen Cache-Schlüssel:

struct QueryKey {
    TypeTag tag;    // Welche Query-Art (ResolveType, GetFileAST, ...)
    ubyte[] data;   // Serialisierte Parameter — in Arena
}

// Automatisch generiert über Hashing:
QueryKey makeKey(Q)(Q query, ref Arena arena) {
    return QueryKey {
        tag:  TypeTag.of!Q,
        data: serialize(query, arena)
    };
}

Wichtig: Zwei Query-Instanzen mit identischen Parametern müssen denselben QueryKey produzieren. Das ist die Grundlage des Cachings.

3.3 Query-Ergebnis (QueryResult)

struct QueryResult {
    ubyte[]  data;        // Serialisiertes Ergebnis — in resultArena
    TypeTag  tag;         // Typ des Ergebnisses
    ulong    changedAt;   // Revision in der sich das Ergebnis zuletzt inhaltlich änderte
    ulong    verifiedAt;  // Revision in der zuletzt geprüft wurde ob das Ergebnis stimmt
}

Semantik: - changedAt — "Mein Ergebnis war in Revision X zuletzt inhaltlich anders als davor." Für Input Queries: wird bei jedem setInput auf currentRevision gesetzt. Für Derived Queries: wird nur gesetzt wenn das neue Ergebnis vom alten abweicht. - verifiedAt — "In Revision X habe ich zuletzt geprüft ob mein Ergebnis noch stimmt." Eine Query ist aktuell wenn verifiedAt == db.currentRevision.

Diese beiden Felder ermöglichen Early Cutoff (Abschnitt 7).

3.4 Query-Kategorien

Es gibt zwei Kategorien von Queries:

Input Queries — werden von außen gesetzt, kein Handler:

struct FileContent { string path; }     // → string
struct BuildConfig { }                  // → Config
struct ModuleList  { }                  // → string[]

Derived Queries — berechnet aus anderen Queries, haben @handles-Handler:

// Parsen — hängt von FileContent ab:
struct GetFileAST      { string path; }              // → AST
struct GetASTNode      { NodeId id; }                // → ASTNode
struct GetModuleId     { string path; }              // → ModuleId

// Auflösen und Typen:
struct ResolveType     { TypeId id; }                // → TypeNode
struct LookupSymbol    { Symbol s; ScopeId scope_; } // → Symbol
struct GetMemoryLayout { TypeId id; }                // → MemoryLayout

// Prüfen — geben Diagnostics zurück:
struct CheckFunction   { FuncId id; }                // → Diagnostic[]
struct CheckEffects    { FuncId id; }                // → Diagnostic[]
struct ValidateModule  { ModuleId id; }              // → Diagnostic[]

4. CompilerDb — die zentrale Datenbank

4.1 Struktur

struct CompilerDb {
    // Subsysteme — nur CompilerDb kennt sie:
    Parser*       parser;
    TypeEngine*   typeEngine;
    NameResolver* resolver;
    JME*          jme;

    // Cache — alle Query-Ergebnisse:
    QueryCache   cache;

    // Dependency-Graph — wer hängt von wem ab:
    DepGraph     deps;

    // Arenen:
    Arena        keyArena;      // QueryKeys leben hier
    Arena        resultArena;   // Query-Ergebnisse leben hier

    // Aktuelle Revision — wird bei jedem setInput inkrementiert:
    ulong        currentRevision;

    // Aktuelle laufende Query (für Dep-Tracking):
    QueryKey*    activeQuery;   // null wenn keine läuft

    // Durability-Tracking — letzte Änderung je Durability-Level:
    ulong[3]     lastChangedByDurability;   // [Low, Medium, High]
}

Input Queries setzen — die setInput-API:

void setInput(Q, V)(CompilerDb* db, Q input, V value, Durability dur = Durability.Medium) {
    auto key = makeKey(input, db.keyArena);
    db.cache.put(key, QueryResult {
        data:       serialize(value, db.resultArena),
        tag:        TypeTag.of!V,
        changedAt:  db.currentRevision,
        verifiedAt: db.currentRevision,
    });
    db.lastChangedByDurability[dur] = db.currentRevision;
    db.currentRevision++;
}

// Einstiegspunkt wenn sich eine Datei ändert:
void fileChanged(CompilerDb* db, string path, string newContent) {
    db.setInput(FileContent { path }, newContent, Durability.Low);
    // Keine transitive Invalidierung — Lazy Revalidierung passiert bei query()
}

4.2 Die query()-Funktion

Das Herzstück des Systems. Implementiert Lazy Revalidierung mit Early Cutoff (vollständige Beschreibung in Abschnitt 7):

T query(T, Q)(CompilerDb* db, Q q) @nogc nothrow pure
    if (isDerivedQuery!Q)
{
    auto key = makeKey(q, db.keyArena);

    // Zyklus-Erkennung:
    if (db.activeQueries.contains(key))
        return handleCycle!T(db, q);   // siehe Abschnitt 18.3

    if (auto cached = db.cache.get(key)) {
        // Schon in dieser Revision verifiziert?
        if (cached.verifiedAt == db.currentRevision) {
            trackDep(db, key);
            return deserialize!T(cached.data);
        }

        // Durability-Kurzschluss: Query hängt nur von stabilen Inputs ab?
        if (canSkipRevalidation(db, key)) {
            cached.verifiedAt = db.currentRevision;
            trackDep(db, key);
            return deserialize!T(cached.data);
        }

        // Dependencies prüfen — haben sich ihre Ergebnisse geändert?
        bool depsChanged = false;
        foreach (dep; db.deps.dependenciesOf(key)) {
            revalidate(db, dep);   // rekursiv
            auto depResult = db.cache.get(dep);
            if (depResult !is null && depResult.changedAt > cached.verifiedAt) {
                depsChanged = true;
                break;
            }
        }

        if (!depsChanged) {
            // Early Cutoff — Ergebnis ist noch gültig!
            cached.verifiedAt = db.currentRevision;
            trackDep(db, key);
            return deserialize!T(cached.data);
        }
    }

    // Neu berechnen:
    auto prevActive = db.activeQuery;
    db.activeQuery = &key;
    db.deps.clearDependencies(key);   // alte Deps löschen — werden neu aufgebaut
    scope(exit) db.activeQuery = prevActive;

    checkCancellation(db);   // vor teurer Berechnung prüfen

    auto result = dispatch!T(db, q);
    auto newData = serialize(result, db.resultArena);

    // Ergebnis inhaltlich vergleichen — Early Cutoff auf Ergebnisebene:
    auto cached = db.cache.get(key);
    bool resultChanged = (cached is null) || !bytesEqual(cached.data, newData);

    db.cache.put(key, QueryResult {
        data:       newData,
        tag:        TypeTag.of!T,
        changedAt:  resultChanged ? db.currentRevision : cached.changedAt,
        verifiedAt: db.currentRevision,
    });

    trackDep(db, key);
    return result;
}

// Dep-Tracking: aktuelle Query hängt von 'key' ab
void trackDep(CompilerDb* db, QueryKey key) @nogc nothrow {
    if (db.activeQuery !is null)
        db.deps.addEdge(*db.activeQuery, key);
}

4.3 Dispatch — UDA-generiert

dispatch() wird nicht manuell geschrieben. Es wird zur Compile-Zeit aus allen @handles-annotierten Handler-Funktionen automatisch generiert:

// compiler_db.d — automatisch generiert, nie manuell bearbeiten:
auto dispatch(T, Q)(CompilerDb* db, Q q) @nogc nothrow pure {
    // __traits sammelt zur Compile-Zeit alle @handles!Q Handler:
    alias handler = getHandler!Q;   // Compile-Zeit UDA-Lookup

    // Duplicate-Check — zwei Handler für dieselbe Query = Compilerfehler:
    static assert(countHandlers!Q == 1,
        "Query " ~ Q.stringof ~ " hat keinen oder mehrere Handler — " ~
        "jede Query braucht genau einen @handles-Handler.");

    return handler(q, db);
}

Neue Query hinzufügen erfordert keinen Dispatch-Eintrag mehr — der Handler registriert sich selbst über @handles. Siehe Abschnitt 16.

4.4 Invalidierung

Eager-rekursive Invalidierung (valid = false auf allen Abhängigen setzen) wird nicht mehr verwendet. An ihrer Stelle steht Lazy Revalidierung: bei jedem setInput wird nur currentRevision inkrementiert. Ob ein Cache-Eintrag noch gültig ist, wird erst bei der nächsten query()-Anfrage geprüft.

Der vollständige Mechanismus ist in Abschnitt 7 beschrieben.


5. Subsystem-Registrierung

5.1 Das @handles UDA

Handler-Funktionen registrieren sich selbst über das @handles UDA. Nur Derived Queries haben @handles-Handler — Input Queries werden über setInput geschrieben, nicht berechnet.

// queries.d — UDA-Definition:
struct handles(Q) {}

// Compile-Zeit Constraint: jede @handles-Funktion muss pure nothrow @nogc sein.
// Der Compiler prüft das statisch — Verletzungen sind Buildfehler.

5.2 Handler-Funktionen

Handler sind freie Funktionen — keine Methoden. Der Subsystem-Zustand kommt immer über db. Handler lesen Dateiinhalte nie direkt — sie lesen den FileContent-Input über db.query:

// parser.d — GetFileAST ist Derived, liest FileContent-Input:
@handles!GetFileAST
AST parseFile(GetFileAST q, CompilerDb* db) @nogc nothrow pure {
    // Liest Input-Query — Dependency wird automatisch registriert:
    auto source = db.query!string(FileContent { q.path });
    return db.parser.parseFromString(source);   // pure Berechnung
}

// type_engine.d
@handles!ResolveType
TypeNode resolveType(ResolveType q, CompilerDb* db) @nogc nothrow pure {
    auto engine = db.typeEngine;
    // Kann andere Queries aufrufen — Dep-Tracking ist automatisch:
    auto symbol = db.query!Symbol(LookupSymbol { q.id, currentScope(db) });
    auto ast    = db.query!AST(GetFileAST { symbol.file });
    return engine.resolveFromAST(ast, symbol);
}

@handles!InternType
TypeId internType(InternType q, CompilerDb* db) @nogc nothrow pure {
    return db.typeEngine.intern(q.node);
}

// resolver.d
@handles!LookupSymbol
Symbol lookupSymbol(LookupSymbol q, CompilerDb* db) @nogc nothrow pure {
    return db.resolver.lookup(q.s, q.scope_);
}

5.3 Subsystem darf nicht direkt auf andere Subsysteme zugreifen

// FALSCH — direkter Zugriff:
@handles!ResolveType
TypeNode resolveType(ResolveType q, CompilerDb* db) @nogc nothrow pure {
    auto scope_ = db.resolver.currentScope;  // VERBOTEN
}

// RICHTIG — immer über DB:
@handles!ResolveType
TypeNode resolveType(ResolveType q, CompilerDb* db) @nogc nothrow pure {
    auto scope_ = db.query!Scope(GetCurrentScope {});  // OK
}

Die Regel ist absolut: Kein Handler greift direkt auf ein fremdes Subsystem zu. Nur die CompilerDb kennt alle Subsysteme — Handler kennen nur db.


6. Dependency-Tracking

6.1 Wie es funktioniert

Das Tracking passiert automatisch — der Entwickler muss nichts tun.

// TypeEngine-Handler für ResolveType:
@handles!ResolveType
TypeNode resolveType(ResolveType q, CompilerDb* db) @nogc nothrow pure {
    // Diese Aufrufe werden automatisch getrackt:
    auto sym = db.query!Symbol(LookupSymbol { q.id, currentScope(db) });  // dep ①
    auto ast = db.query!AST(GetFileAST { sym.file });                      // dep ②
    // ...
}

// DB registriert automatisch:
// ResolveType(userId) → hängt ab von:
//   ① LookupSymbol(auth::User)
//   ② GetFileAST("auth/model.jdl")
//      └── GetFileAST hängt ab von FileContent("auth/model.jdl")  ← Input

6.2 Der Dependency-Graph

struct DepGraph {
    // Vorwärts-Kanten: welche Queries hängen von dieser Query ab?
    // (genutzt bei Invalidierung — rückwärts zu früheren Versionen)
    QueryKey[][QueryKey] dependents;

    // Rückwärts-Kanten: welche Queries ruft diese Query auf?
    // (genutzt bei Lazy Revalidierung — vorwärts)
    QueryKey[][QueryKey] dependencies;

    void addEdge(QueryKey from, QueryKey to) @nogc {
        dependents[to]      ~= from;   // to ← from (rückwärts)
        dependencies[from]  ~= to;     // from → to (vorwärts)
    }

    // Wer hängt von 'key' ab? — rückwärts, für eager Invalidierung (legacy)
    QueryKey[] dependentsOf(QueryKey key) @nogc {
        return dependents.get(key, []);
    }

    // Was hängt 'key' ab von? — vorwärts, für Lazy Revalidierung
    QueryKey[] dependenciesOf(QueryKey key) @nogc {
        return dependencies.get(key, []);
    }

    // Deps einer Query löschen — nötig vor Neuberechnung damit
    // veraltete Kanten nicht im Graph bleiben:
    void clearDependencies(QueryKey key) @nogc {
        // Rückwärts-Kanten aus 'dependents' der alten Deps entfernen:
        foreach (dep; dependencies.get(key, [])) {
            dependents[dep] = dependents[dep].without(key);
        }
        dependencies.remove(key);
    }
}

6.3 Visualisierung (für Debugging)

void dumpDepGraph(CompilerDb* db) {
    foreach (from, deps; db.deps.dependencies) {
        writeln(queryName(from), " → hängt ab von:");
        foreach (dep; deps)
            writeln("    ", queryName(dep));
    }
}

// Ausgabe bei einer typischen Compilation:
// ResolveType(auth::User) → hängt ab von:
//     LookupSymbol(auth::User)
//     GetFileAST("auth/model.jdl")
// GetFileAST("auth/model.jdl") → hängt ab von:
//     FileContent("auth/model.jdl")   ← Input Query
// CheckFunction(handleRequest) → hängt ab von:
//     ResolveType(auth::User)
//     ResolveType(billing::Order)
//     LookupSymbol(auth::handleRequest)

7. Lazy Revalidierung — Early Cutoff

7.1 Grundidee

Der alte Ansatz war eager-rekursiv: Bei einer Dateiänderung wurden sofort alle transitiv Abhängigen auf "invalid" gesetzt. Das führt zu unnötigen Neuberechnungen wenn sich das Ergebnis einer Zwischenquery nicht ändert.

Beispiel: Ein Kommentar wird geändert. Der AST ist inhaltlich identisch. Mit eager Invalidierung werden ResolveType, CheckFunction und alle davon Abhängigen trotzdem als invalid markiert und neu berechnet — obwohl kein einziger Typ oder Check davon betroffen ist.

Der neue Ansatz ist lazy: Nichts wird beim setInput invalidiert. Erst wenn eine Query aufgerufen wird, prüft die DB ob ihr Ergebnis noch stimmt — und nur wenn eine Dependency sich inhaltlich geändert hat, wird neu berechnet.

7.2 Revalidierungs-Algorithmus

Bei setInput(q, v): 1. Cache-Eintrag für q schreiben mit changedAt = currentRevision 2. currentRevision++ 3. Keine transitive Invalidierung.

Bei query(Q q) für eine Derived Query: 1. Cache-Eintrag suchen. Nicht vorhanden → Neu berechnen (Schritt 5). 2. verifiedAt == currentRevision? → Gecacht, fertig (Early Cutoff auf Cache-Ebene). 3. Durability-Kurzschluss: Wenn die Query nur von High-Durability-Inputs abhängt und keiner davon sich seit verifiedAt geändert hat → verifiedAt aktualisieren, fertig (kein Dep-Traversal nötig). 4. Dependencies durchgehen, jede rekursiv revalidieren. Hat keine Dependency ein changedAt > cached.verifiedAt? → Ergebnis noch gültig, verifiedAt aktualisieren, fertig. Early Cutoff — keine Neuberechnung. 5. Neu berechnen. Ergebnis mit altem vergleichen: - Inhaltlich identisch → changedAt bleibt, nur verifiedAt aktualisieren. Early Cutoff auf Ergebnisebene — Abhängige dieser Query werden ebenfalls nicht unnötig neu berechnet. - Inhaltlich verschieden → changedAt = currentRevision.

7.3 Beispiel: Kommentar-Änderung vs. Typänderung

Ausgangszustand — Revision 5:
    FileContent("auth/model.jdl")     changedAt=3  verifiedAt=5
    GetFileAST("auth/model.jdl")      changedAt=3  verifiedAt=5
    ResolveType(auth::User)           changedAt=3  verifiedAt=5
    CheckFunction(findUser)           changedAt=3  verifiedAt=5

--- Szenario A: Kommentar-Änderung ---

fileChanged(db, "auth/model.jdl", newSource)   // nur Kommentar geändert
→ FileContent.changedAt = 6,  currentRevision = 6

db.query(CheckFunction { findUserId })

  → CheckFunction: verifiedAt=5 < 6 → prüfe Dependencies
    → ResolveType: verifiedAt=5 < 6 → prüfe Dependencies
      → GetFileAST: verifiedAt=5 < 6 → prüfe Dependencies
        → FileContent: changedAt=6 > verifiedAt=5 → Neu berechnen
          Neues AST: inhaltlich identisch (nur Kommentar)
          → changedAt bleibt 3, verifiedAt=6
      → GetFileAST.changedAt=3 ≤ ResolveType.verifiedAt=5 → Early Cutoff!
        → ResolveType.verifiedAt=6  (kein Neu-Berechnen)
    → ResolveType.changedAt=3 ≤ CheckFunction.verifiedAt=5 → Early Cutoff!
      → CheckFunction.verifiedAt=6  (kein Neu-Berechnen)

Kosten: GetFileAST neu berechnet (AST-Parse). Alles andere: nur verifiedAt aktualisiert.

--- Szenario B: Echter Typ-Umbau ---

fileChanged(db, "auth/model.jdl", newSource)   // Feld 'age: i32' → 'age: u32'
→ FileContent.changedAt = 7,  currentRevision = 7

db.query(CheckFunction { findUserId })

  → Dep-Traversal wie oben, aber:
    GetFileAST: Neu berechnen — AST ist anders
    → changedAt=7, verifiedAt=7
  → ResolveType.dep GetFileAST.changedAt=7 > ResolveType.verifiedAt=6 → Neu berechnen
    → Typänderung erkannt → changedAt=7, verifiedAt=7
  → CheckFunction.dep ResolveType.changedAt=7 → Neu berechnen
    → CheckFunction.changedAt=7, verifiedAt=7

Kosten: Alle drei neu berechnet — korrekt, weil sich Typen geändert haben.

8. Durability

8.1 Motivation

Bei jedem Tastendruck startet der LSP eine Revalidierung. Queries die nur von Stdlib-Definitionen abhängen — z.B. ResolveType(i32) — ändern sich nie zwischen zwei Edits. Ohne Durability traversiert die Lazy Revalidierung trotzdem die gesamte Dependency-Kette bis zu den Stdlib-Inputs.

Durability ermöglicht es, diese Traversal vollständig zu überspringen.

8.2 Durability-Klassifikation

enum Durability {
    Low,      // ändert sich häufig (aktive Datei im Editor)
    Medium,   // ändert sich selten (andere Projektdateien)
    High      // ändert sich fast nie (Stdlib, externe Dependencies)
}

Jeder Input wird bei setInput mit einer Durability versehen:

// Aktive Datei — Low:
db.setInput(FileContent { editedFile }, newContent, Durability.Low);

// Projektdatei im Hintergrund — Medium (Default):
db.setInput(FileContent { otherFile }, content, Durability.Medium);

// Stdlib — High (einmalig beim Start):
db.setInput(FileContent { "std/core.jdl" }, stdContent, Durability.High);

Die CompilerDb trackt pro Durability-Level die letzte Revision in der sich ein Input dieser Klasse geändert hat:

// Beispiel nach einigen Edits:
// db.lastChangedByDurability[High]   = 3    (Stdlib, vor 39 Revisionen)
// db.lastChangedByDurability[Medium] = 15   (Projektdatei, vor 27 Revisionen)
// db.lastChangedByDurability[Low]    = 42   (aktive Datei, gerade geändert)

8.3 Kurzschluss-Optimierung

Eine Derived Query erbt die maximale Durability aller ihrer transitiven Abhängigkeiten. Wenn für eine Query gilt:

verifiedAt >= lastChangedByDurability[maxDurability der Deps]

kann die gesamte Revalidierungs-Traversal übersprungen werden — ohne einen einzigen Dep-Graph-Schritt:

bool canSkipRevalidation(CompilerDb* db, QueryKey key) @nogc nothrow {
    auto maxDur = db.maxDurabilityOf(key);   // gecacht im QueryResult
    return db.cache.get(key).verifiedAt >= db.lastChangedByDurability[maxDur];
}

8.4 Beispiel

ResolveType(i32) hängt nur von High-Durability-Inputs ab (Stdlib).
lastChangedByDurability[High] = 3.
ResolveType(i32).verifiedAt = 40.

→ 40 >= 3 → canSkipRevalidation = true → sofort fertig.

Kein Dep-Graph-Traversal. Kein Cache-Lookup für Dependencies.
Bei jedem Tastendruck: eine Prüfung, dann fertig.

9. Cancellation

9.1 Motivation

Wenn der Benutzer schnell tippt, startet der LSP bei jedem Tastendruck eine Neuberechnung. Ohne Cancellation laufen alte Berechnungen zu Ende obwohl ihr Ergebnis bereits veraltet ist. Das kostet unnötig Zeit und verzögert das Feedback für den aktuellen Stand.

9.2 Mechanismus

struct CompilerDb {
    // ... bestehende Felder ...
    bool cancellationRequested;   // gesetzt vom LSP, geprüft in query()
}

// An definierten Checkpoints vor jeder teuren Neuberechnung:
void checkCancellation(CompilerDb* db) @nogc nothrow {
    if (db.cancellationRequested)
        throw CancelledException();   // oder longjmp, oder Return-Code
}

Der Checkpoint wird in query() direkt vor dispatch() eingesetzt (bereits im Code in Abschnitt 4.2 markiert). Ein Checkpoint vor jeder Neuberechnung ist ausreichend — gecachte Ergebnisse werden nie gecancelt.

9.3 LSP-Integration

void didChange(CompilerDb* db, string file, string content) {
    // Laufende Berechnung unterbrechen:
    db.cancellationRequested = true;
    waitForCurrentQuery(db);           // warten bis laufende query() endet

    // Flag zurücksetzen und neuen Stand eintragen:
    db.cancellationRequested = false;
    db.setInput(FileContent { file }, content, Durability.Low);

    // Neue Berechnung starten (z.B. Diagnostics für aktuelle Datei):
    scheduleRevalidation(db);
}

9.4 Cache-Sicherheit

Cancellation darf den Cache nicht korrumpieren. Die Garantie:

  • Wird eine Berechnung abgebrochen bevor db.cache.put aufgerufen wird, bleibt der alte Cache-Eintrag erhalten.
  • Ein abgebrochenes dispatch() schreibt nie einen Teilzustand in den Cache.
  • Beim nächsten Aufruf wird die Berechnung vollständig neu gestartet und die alten Dependencies via clearDependencies bereinigt.

Hinweis zur pure-Invariante: checkCancellation liest ein externes Flag und ist damit technisch nicht pure. Das ist akzeptabel weil Cancellation keine semantische Änderung am Ergebnis verursacht — sie verhindert nur die Berechnung. Alternativ: das Flag über den db-Parameter durchreichen — dann bleibt die Funktion weakly pure, weil db ein Parameter ist.


10. Arena-Integration

10.1 Jedes Subsystem hat seine eigene Arena

struct Compiler {
    // Subsysteme — jedes mit eigener Arena:
    Parser       parser;      // parser.arena
    TypeEngine   typeEngine;  // typeEngine.arena
    NameResolver resolver;    // resolver.arena

    // CompilerDb hat eigene Arenen für ihre Verwaltungsdaten:
    CompilerDb   db;          // db.keyArena, db.resultArena, db.depArena

    // Consumer — eigene Arenen, aber nicht Teil der CompilerDb:
    Generator    generator;   // generator.arena
}

10.2 Lebenszeiten

Compilation Start
├── typeEngine.arena.open()     ← lebt am längsten — Typen braucht man immer
│   ├── parser.arena.open()
│   │   parser.parse(src)
│   │   parser.arena.reset()    ← nach AST-Verarbeitung
│   │
│   ├── resolver.arena.open()
│   │   resolver.resolve(...)
│   │   resolver.arena.reset()  ← nach Name Resolution
│   │
│   ├── generator.arena.open()  ← Consumer — liest aus DB, schreibt Artefakte
│   │   generator.emit(db)
│   │   generator.arena.reset() ← nach Bytecode-Emission
│   │
└── typeEngine.arena.reset()    ← wenn JME übernimmt

10.3 Queries und Arenen

Query-Ergebnisse die über Subsystem-Grenzen gehen leben in db.resultArena:

T query(T, Q)(CompilerDb* db, Q q) @nogc {
    // ...
    auto result = dispatch!T(db, q);

    // Ergebnis in resultArena kopieren — nicht in Subsystem-Arena!
    db.cache.put(key, QueryResult {
        data: serialize(result, db.resultArena),   // ← resultArena
        // ...
    });

    return result;
}

Warum: Ein Subsystem kann seine Arena jederzeit resetten. Wenn ein anderes Subsystem auf ein Ergebnis zeigt das in einer freigegebenen Arena liegt, gibt es Use-After-Free. resultArena lebt solange der Cache gültig ist.


11. Fehlerbehandlung

11.1 Diagnostics als First-Class Queries

Fehler sind keine Exceptions — sie sind Query-Ergebnisse:

struct CheckFunction {
    FuncId id;
    // → Diagnostic[]
}

@handles!CheckFunction
Diagnostic[] checkFunction(CheckFunction q, CompilerDb* db) @nogc nothrow pure {
    auto func = db.query!FuncNode(GetFunction { q.id });

    Diagnostic[] diags;

    // Typen prüfen:
    foreach (stmt; func.body) {
        if (auto err = checkStatement(stmt, db))
            diags ~= *err;
    }

    return diags;
}

11.2 Fehler stoppen die Propagierung nicht

Eine Query die Fehler enthält gibt diese zurück — sie wirft nicht:

// FALSCH — Exception wirft den Dep-Graph durcheinander:
@handles!ResolveType
TypeNode resolveType(ResolveType q, CompilerDb* db) @nogc nothrow pure {
    if (!found) throw new Exception("Type not found");  // NIEMALS
}

// RICHTIG — Fehler als Wert via JadeResult:
@handles!ResolveType
JadeResult!TypeNode resolveType(ResolveType q, CompilerDb* db) @nogc nothrow pure {
    if (!found) return JadeResult!TypeNode.err(Diagnostic {
        phase:    Phase.TypeEngine,
        severity: Severity.Error,
        message:  "Type not found: " ~ symbolName,
        span:     sourceSpan
    });
    return JadeResult!TypeNode.ok(node);
}

// Kein Wert aber kein Fehler — Empty statt Option:
@handles!GetDocComment
JadeResult!DocNode getDocComment(GetDocComment q, CompilerDb* db) @nogc nothrow pure {
    if (!hasComment) return JadeResult!DocNode.empty();
    return JadeResult!DocNode.ok(docNode);
}

11.3 JadeResult — der einheitliche Ergebnistyp

Alle Handler-Funktionen die einen einzelnen Wert zurückgeben verwenden JadeResult(T). Queries die eine Liste von Diagnostics zurückgeben (Check-Queries) geben Diagnostic[] zurück.

JadeResult(T) hat drei Zustände:

Zustand Bedeutung
Ok(T) Erfolg mit Wert
Err(Diagnostic) Fehler mit strukturierter Beschreibung
Empty Korrekt ausgeführt, kein Wert — kein Fehler

Vollständige Definition: siehe jade-result-spec.md.

11.4 Diagnostic-Struktur

struct Diagnostic {
    Phase      phase;      // Lexer | Parser | TypeEngine | Resolver | ...
    Severity   severity;   // Error | Warning | Info | Hint
    SourceSpan span;       // Hauptstelle im Code
    string     message;    // Hauptmeldung
    Hint[]     hints;      // Zeigen auf andere Stellen
    Note[]     notes;      // Erklärende Notizen ohne Span
}

struct Hint {
    SourceSpan span;
    string     message;    // konstruktiv: "Füge X hinzu"
}

struct Note {
    string message;        // erklärend: "weil Y..."
}

12. Emergente Tools — Consumer der Query API

12.1 Das Consumer-Konzept

Ein Consumer ist ein Tool das außerhalb der CompilerDb sitzt, aber ihre Queries lesend nutzt. Consumer registrieren keine Handler, besitzen keine Arena und sind keine Subsysteme — sie koordinieren selbst welche Queries sie in welcher Reihenfolge stellen.

Parser    TypeEngine    Resolver    JME
   ↓           ↓           ↓        ↓
   └───────────┴───────────┴────────┘
                    CompilerDb
                 (Cache + DepGraph)
                    ↑        ↑        ↑
             LspServer  DocGenerator  Generator    (Consumers)

Das Query-System erzwingt keine bestimmten Consumer — es macht sie möglich. LSP, Dokumentationsgenerator, statische Analyse-Tools, Code-Formatter: alle emergieren aus denselben Primitiven.

12.2 LSP als Consumer

Das LSP ist ein Consumer der CompilerDb. Es nutzt bestehende Queries um Language-Server-Protocol-Antworten zu komponieren:

struct LspServer {
    CompilerDb* db;

    // Hover: kombiniert Symbol, Typ und Dokumentation
    HoverResult hover(string file, Position pos) {
        auto symbol = db.query!Symbol(GetSymbolAt { file, pos });
        auto type   = db.query!TypeNode(ResolveType { symbol.typeId });
        auto docs   = db.query!string(GetDocComment { symbol });

        return HoverResult {
            markdown: renderHover(symbol, type, docs)
        };
    }

    // Diagnostics: delegiert direkt an die CheckModule-Query
    Diagnostic[] diagnostics(string file) {
        auto moduleId = db.query!ModuleId(GetModuleId { file });
        return db.query!(Diagnostic[])(ValidateModule { moduleId });
    }

    // Datei geändert: neuen Inhalt als Input setzen, Cancellation triggern
    void didChange(string file, string newContent) {
        db.cancellationRequested = true;
        waitForCurrentQuery(db);
        db.cancellationRequested = false;
        db.setInput(FileContent { file }, newContent, Durability.Low);
    }
}

Inkrementelle Updates entstehen nicht weil der LSP sie implementiert — sondern weil Cache, DepGraph und Lazy Revalidierung es automatisch liefern.

Offene Frage: Die konkrete Implementierung des LSP-Servers (Protokoll- Handling, JSON-RPC, Client-Kommunikation) ist noch nicht spezifiziert. Das ist eine separate Entscheidung die von dieser Spec unabhängig ist.

12.3 jade doc als Consumer

jade doc --generate ist ein weiterer Consumer. Er führt intern einen vollständigen Compile-Lauf durch und sammelt dann Dokumentationsdaten aus denselben Queries die auch der Compiler nutzt:

struct DocGenerator {
    CompilerDb* db;

    void generate(ModuleId[] modules) {
        foreach (moduleId; modules) {
            // Symbole — aus NameResolver
            auto symbols = db.query!(Symbol[])(GetExports { moduleId });

            foreach (sym; symbols) {
                // Typ-Information — aus TypeEngine
                auto type    = db.query!TypeNode(ResolveType { sym.typeId });
                // Memory-Label — aus JME
                auto layout  = db.query!MemoryLayout(GetMemoryLayout { sym.typeId });
                // Effekte — aus TypeEngine
                auto effects = db.query!EffectSig(GetEffectSignature { sym.funcId });
                // Dokumentations-Kommentar — aus Parser
                auto docs    = db.query!DocNode(GetDocComment { sym });

                emit(renderDocEntry(sym, type, layout, effects, docs));
            }
        }
    }
}

Die Dokumentation kann nicht lügen — sie kommt aus denselben Queries wie der Compiler selbst. Typ-Constraints, Memory-Labels und Effect-Signaturen sind strukturell immer in sync mit dem Code.

Offene Frage: HTML-Rendering, Template-System und die genaue Struktur der DocNode-Ausgabe sind noch nicht spezifiziert.

12.4 Weitere mögliche Consumer

Dieselbe Architektur ermöglicht ohne Änderungen an der CompilerDb:

  • Formatter — liest AST via GetFileAST, schreibt formattierten Code
  • Linter — komponiert Check-Queries mit eigenen Heuristiken
  • Refactoring-Tools — nutzt GetReferences, GetDefinition für sichere Umbenennungen
  • Build-System-Integration — nutzt ModuleList (Input) und GetExports für Dependency-Graphen

Alle emergieren aus dem Query-System — keiner braucht eine neue Architektur.


13. jade doc — Query-basierter Dokumentationsgenerator

13.1 Designprinzip

jade doc ist kein separates Tool das Quelltext liest und interpretiert — es ist ein Consumer der CompilerDb. Die Dokumentation entsteht aus denselben Queries die der Compiler selbst nutzt. Sie kann nicht lügen: Typ-Constraints, Memory-Labels und Effect-Signaturen sind strukturell immer in sync mit dem Code weil sie aus denselben Quellen kommen.

Quelltext → CompilerDb → jade doc → HTML-Dokumentation
         (dieselben Queries wie jade build)

13.2 Aktivierung — keepDocComments

Doc-Kommentare (///) sind im normalen Compile-Lauf Trivia — sie werden verworfen. jade doc setzt beim CLI-Entry-Point das Flag keepDocComments direkt bevor die CompilerDb aufgebaut wird. Der Parser behandelt ///-Kommentare dann als first-class DocNode-Knoten im AST.

jade doc --generate
    ├── keepDocComments = true      ← CLI setzt Flag
    ├── CompilerDb aufbauen
    ├── vollständiger Compile-Lauf  ← identisch zu jade build
    └── DocGenerator.generate(db)  ← Consumer liest aus DB

Ohne keepDocComments existiert GetDocComment als Query — sie gibt nur immer None zurück.

13.3 Was der DocGenerator konsumiert

Der DocGenerator komponiert für jedes exportierte Symbol folgende Queries:

struct DocGenerator {
    CompilerDb* db;

    void generate(ModuleId[] modules) {
        foreach (moduleId; modules) {
            auto symbols = db.query!(Symbol[])(GetExports { moduleId });

            foreach (sym; symbols) {
                auto type    = db.query!TypeNode(ResolveType { sym.typeId });
                auto layout  = db.query!MemoryLayout(GetMemoryLayout { sym.typeId });
                auto effects = db.query!EffectSig(GetEffectSignature { sym.funcId });
                auto docs    = db.query!DocNode(GetDocComment { sym });

                emit(renderDocEntry(sym, type, layout, effects, docs));
            }
        }
    }
}

13.4 Markdown in Kommentaren

Doc-Kommentare im Quelltext sind Markdown:

/// Registriert einen neuen Benutzer im System.
///
/// Validiert E-Mail und Alter bevor der User in die Datenbank
/// geschrieben wird. Schlägt fehl mit `AlreadyExists` wenn die
/// E-Mail bereits vergeben ist.
def registerUser(db: UserDb, name: str, email: str, age: i32) -> Result[User, UserError]

Der Parser extrahiert den Markdown-Text in den DocNode. Die HTML-Ausgabe rendert ihn — zusammen mit den automatisch extrahierten Typ-, Memory- und Effect-Informationen.

13.5 Offene Punkte

  • HTML-Rendering und Template-System sind noch nicht spezifiziert
  • Die genaue Struktur von DocNode ist noch nicht spezifiziert
  • jade doc --generate als CLI-Einstiegspunkt ist definiert — weitere Flags (Ausgabepfad, Format, Filterung) sind noch offen

14. Vollständiges Beispiel — End-to-End

14.1 Szenario

// auth/model.jdl
pub type User: struct { name: str, email: str, age: i32 }

// auth/service.jdl
import auth::model : User

pub def findUser(db: UserDb, email: str) -> Result[User, UserError] =
    db.getUserByEmail(email)

14.2 Compilation Flow

void compile(CompilerDb* db, Generator* gen, string[] files) @nogc {

    // Setup — Dateiinhalte als Inputs setzen:
    foreach (file; files) {
        db.setInput(FileContent { file }, readFile(file), Durability.Medium);
    }

    // Phase 1 — Dateien parsen (GetFileAST liest FileContent-Input):
    foreach (file; files) {
        auto ast = db.query!AST(GetFileAST { file });
    }

    // Phase 2 — Symbole sammeln:
    foreach (file; files) {
        auto moduleId = db.query!ModuleId(GetModuleId { file });
        db.query!(Symbol[])(CollectSymbols { moduleId });
    }

    // Phase 3 — Namen auflösen:
    foreach (file; files) {
        auto moduleId = db.query!ModuleId(GetModuleId { file });
        db.query!(Scope)(ResolveNames { moduleId });
    }
    // → resolver.arena.reset() — Scope-Daten nicht mehr nötig

    // Phase 4 — Typen prüfen:
    foreach (file; files) {
        auto moduleId = db.query!ModuleId(GetModuleId { file });
        auto diags = db.query!(Diagnostic[])(CheckModule { moduleId });
        if (diags.hasErrors) return;
    }

    // Phase 5 — Code generieren (Generator als Consumer — kein Query):
    auto modules = db.query!(ModuleId[])(GetModuleList {});
    gen.emit(db, modules);
    // → generator.arena.reset() — IR-Zwischendarstellung nicht mehr nötig

    // typeEngine.arena bleibt — JME braucht Typinfo
}

14.3 Detaillierter Query-Flow für findUser

db.query(CheckFunction { findUserId })
    ├── db.query(GetFunctionAST { findUserId })
    │       └── db.query(GetFileAST { "auth/service.jdl" })
    │               └── db.query(FileContent { "auth/service.jdl" })   ← Input
    │                       → gecacht
    ├── db.query(ResolveType { userTypeId })
    │       └── db.query(LookupSymbol { auth::User, moduleScope })
    │               └── db.query(GetFileAST { "auth/model.jdl" })
    │                       → [gecacht] → sofort
    ├── db.query(ResolveType { resultTypeId })
    │       └── [gecacht] → sofort
    └── db.query(CheckEffects { findUserId })
            → prüft alle Effekt-Deklarationen, gibt Diagnostic[] zurück

Dep-Graph danach:
CheckFunction(findUser) → hängt ab von:
    GetFunctionAST(findUser)
    GetFileAST("auth/service.jdl") → FileContent("auth/service.jdl")  [Input]
    ResolveType(auth::User)
    GetFileAST("auth/model.jdl")   → FileContent("auth/model.jdl")    [Input]
    ResolveType(Result)

14.4 Inkrementelles Update mit Early Cutoff

User ändert auth/model.jdl — fügt einen Kommentar hinzu:

db.fileChanged("auth/model.jdl", newContent)
→ FileContent("auth/model.jdl").changedAt = currentRevision++

db.query(CheckFunction { findUserId })

Lazy Revalidierung:
    CheckFunction: verifiedAt < currentRevision → prüfe Deps
      ResolveType: verifiedAt < currentRevision → prüfe Deps
        GetFileAST: verifiedAt < currentRevision → prüfe Deps
          FileContent: changedAt = neu → Neu berechnen
            GetFileAST neu berechnen → AST identisch (nur Kommentar)
            → changedAt bleibt alt, verifiedAt = currentRevision
        GetFileAST.changedAt unverändert → ResolveType Early Cutoff!
      ResolveType.changedAt unverändert → CheckFunction Early Cutoff!

Ergebnis: nur GetFileAST neu berechnet. Kein ResolveType, kein CheckFunction.

15. Query-Katalog — Alle definierten Queries

15.1 Input Queries

Input Queries haben keinen @handles-Handler. Sie werden über setInput gesetzt.

// Dateiinhalt — Quelle aller Derived Parser-Queries:
struct FileContent {
    string path;
    // → string
}

// Build-Konfiguration — CLI-Flags, Target-Platform, etc.:
struct BuildConfig {
    // → Config
}

// Liste aller bekannten Quelldateien im Projekt:
struct ModuleList {
    // → string[]
}

15.2 Parser-Queries

// Gesamten AST einer Datei lesen (hängt von FileContent ab):
struct GetFileAST {
    string path;
    // → AST
}

// Einzelnen AST-Knoten lesen:
struct GetASTNode {
    NodeId id;
    // → ASTNode
}

// ModuleId für einen Dateipfad:
struct GetModuleId {
    string path;
    // → ModuleId
}

// Dokumentations-Kommentar einer Deklaration:
// Nur verfügbar wenn der Parser mit keepDocComments=true konfiguriert ist.
struct GetDocComment {
    Symbol s;
    // → Option[DocNode]
}

15.3 NameResolver-Queries

// Symbol in einem Scope nachschlagen:
struct LookupSymbol {
    Symbol  s;
    ScopeId scope_;
    // → Symbol
}

// Alle Symbole die ein Modul exportiert:
struct GetExports {
    ModuleId module_;
    // → Symbol[]
}

// Scope für ein Modul aufbauen:
struct ResolveNames {
    ModuleId module_;
    // → Scope
}

// Symbole in einem Modul sammeln:
struct CollectSymbols {
    ModuleId module_;
    // → Symbol[]
}

// Symbol an einer bestimmten Quelltext-Position auflösen:
struct GetSymbolAt {
    string   file;
    Position pos;
    // → Symbol
}

// Alle Verwendungsstellen eines Symbols:
struct GetReferences {
    Symbol s;
    // → SourceSpan[]
}

// Definitionsstelle eines Symbols:
struct GetDefinition {
    Symbol s;
    // → SourceSpan
}

15.4 TypeEngine-Queries

// Typ auflösen:
struct ResolveType {
    TypeId id;
    // → TypeNode
}

// Typ internen:
struct InternType {
    TypeNode node;
    // → TypeId
}

// Meta-Record eines Typs:
struct GetMetaRecord {
    TypeId id;
    // → MetaRecord
}

// Prüfen ob ein Typ ein Protocol implementiert:
struct ImplementsProtocol {
    TypeId   typeId;
    TypeId   protocolId;
    // → bool
}

// Alle Protokolle die ein Typ implementiert:
struct GetProtocols {
    TypeId id;
    // → TypeId[]
}

// Typ-Konversion prüfen:
struct CanConvert {
    TypeId from;
    TypeId to;
    // → ConversionKind | None
}

// Effect-Signatur einer Funktion:
struct GetEffectSignature {
    FuncId id;
    // → EffectSig
}

15.5 Check-Queries (Diagnostics)

// Eine Funktion typprüfen:
struct CheckFunction {
    FuncId id;
    // → Diagnostic[]
}

// Effekte einer Funktion prüfen:
struct CheckEffects {
    FuncId id;
    // → Diagnostic[]
}

// Memory-Policies eines Typs prüfen:
struct CheckMemoryPolicies {
    TypeId id;
    // → Diagnostic[]
}

// Gesamtes Modul prüfen:
struct CheckModule {
    ModuleId id;
    // → Diagnostic[]
}

// Alle Diagnostics für eine Datei:
struct ValidateModule {
    ModuleId id;
    // → Diagnostic[]
}

15.6 JME-Queries

// Memory-Layout eines Typs:
struct GetMemoryLayout {
    TypeId id;
    // → MemoryLayout
}

// Größe eines Typs in Bytes:
struct GetTypeSize {
    TypeId id;
    // → usize
}

// Alignment eines Typs:
struct GetTypeAlignment {
    TypeId id;
    // → usize
}

15.7 Hinweis: Generator als Consumer

Der Code Generator ist kein Subsystem das Handler registriert — er ist ein Consumer. Er liest aus der CompilerDb (TypeEngine, NameResolver, JME-Queries) und produziert Artefakte (VM-Bytecode, Bridge-Code) als direkten Output.

IR-Generierung und Bytecode-Emission sind keine Queries — sie sind der abschließende Ausgabeschritt den ein Consumer (jade build, jade doc, etc.) explizit anstößt.

15.8 Hinweis: Consumer-spezifische Operationen

Queries die spezifisch für einen Consumer sind — wie Hover-Rendering, Completion-Listen oder Inlay-Hints für den LSP, oder HTML-Ausgabe für jade doc — gehören nicht in diesen Katalog.

Sie sind keine Subsystem-Queries sondern Komposition bestehender Queries durch den jeweiligen Consumer. Ihre Definition liegt beim Consumer selbst.


16. Erweiterung — Neue Queries hinzufügen

16.1 Checkliste

Eine neue Derived Query hinzufügen erfordert genau zwei Schritte:

☐ 1. Query-Struct definieren (in queries.d)
☐ 2. Handler-Funktion mit @handles schreiben (im zuständigen Subsystem)

Eine neue Input Query hinzufügen erfordert einen Schritt:

☐ 1. Query-Struct definieren (in queries.d)
   setInput wird vom Consumer aufgerufen — kein Handler nötig.

Sonst nichts. Kein Dispatch-Eintrag, kein bestehendes Subsystem muss angefasst werden. Der UDA-generierte Dispatch erkennt den neuen Handler automatisch zur Compile-Zeit.

16.2 Beispiel: Neue Query GetFunctionCallGraph

Schritt 1 — Query-Struct:

// queries.d
struct GetFunctionCallGraph {
    FuncId id;
    // → FuncId[]  (alle direkt aufgerufenen Funktionen)
}

Schritt 2 — Handler-Funktion:

// type_engine.d
@handles!GetFunctionCallGraph
FuncId[] getFunctionCallGraph(GetFunctionCallGraph q, CompilerDb* db) @nogc nothrow pure {
    auto func = db.query!FuncNode(GetFunction { q.id });

    // Arena-Slice statt dynamischem Array — @nogc konform:
    auto callees = db.resultArena.allocSlice!FuncId(func.calls.length);
    foreach (i, call; func.calls)
        callees[i] = db.query!FuncId(LookupSymbol { call.symbol });

    return callees;
}

Fertig. Die neue Query ist ab sofort über db.query!(FuncId[])(GetFunctionCallGraph { id }) verfügbar — mit automatischem Caching, Dependency-Tracking und Duplicate-Check.


17. Invarianten und Regeln

Diese Regeln sind nicht verhandelbar. Sie sichern die Korrektheit des gesamten Systems.

Invariante 1 — Keine direkte Subsystem-Kommunikation

// VERBOTEN:
db.typeEngine.arena            // fremde Arena
db.parser.currentFile          // fremdes internes State
db.resolver.lookupDirect(...)  // direkte Methode

// ERLAUBT:
db.query(ResolveType { ... })  // immer über DB

Invariante 2 — Queries sind deterministisch

// VERBOTEN — nicht-deterministisch:
struct GetTimestamp { }
// → result hängt von Ausführungszeit ab → kein sinnvolles Caching

// ERLAUBT — deterministisch:
struct FileContent { string path; }
// → gleiche Datei → gleicher Inhalt (bis zum nächsten setInput)

Invariante 3 — Derived Queries haben keine Seiteneffekte

Diese Invariante gilt ausschließlich für Derived Queries mit @handles-Handler. Input Queries werden über setInput von außen gesetzt und unterliegen nicht dieser Regel.

// VERBOTEN:
@handles!ResolveType
TypeNode resolveType(ResolveType q, CompilerDb* db) @nogc nothrow pure {
    globalLog.write("resolving " ~ q.id);   // Seiteneffekt — VERBOTEN
    // ...
}

// ERLAUBT — Diagnostics als Rückgabewert:
@handles!ResolveType
JadeResult!TypeNode resolveType(ResolveType q, CompilerDb* db) @nogc nothrow pure {
    // Fehler als Wert zurückgeben, nicht als Seiteneffekt
}

Invariante 4 — Kein Schreiben in fremde Arenen

// VERBOTEN:
@handles!ResolveType
TypeNode resolveType(ResolveType q, CompilerDb* db) @nogc nothrow pure {
    auto node = allocateIn(db.parser.arena, ...);  // fremde Arena — VERBOTEN
}

// ERLAUBT:
@handles!ResolveType
TypeNode resolveType(ResolveType q, CompilerDb* db) @nogc nothrow pure {
    auto node = allocateIn(db.typeEngine.arena, ...);  // eigene Arena
    // oder:
    auto node = allocateIn(db.resultArena, ...);       // resultArena für Cross-Subsystem-Daten
}

Invariante 5 — Handler dürfen nie null zurückgeben

// VERBOTEN:
@handles!ResolveType
TypeNode resolveType(ResolveType q, CompilerDb* db) @nogc nothrow pure {
    if (!found) return null;  // null ist kein gültiges Ergebnis — VERBOTEN
}

// ERLAUBT:
@handles!ResolveType
JadeResult!TypeNode resolveType(ResolveType q, CompilerDb* db) @nogc nothrow pure {
    if (!found) return JadeResult!TypeNode.err(Diagnostic { ... });
}
// oder:
@handles!ResolveType
Option!TypeNode resolveType(ResolveType q, CompilerDb* db) @nogc nothrow pure {
    if (!found) return none;
}

Invariante 6 — Zyklische Abhängigkeiten werden kategoriebasiert behandelt

Zyklische Query-Abhängigkeiten sind nicht pauschal verboten — sie werden je nach Query-Typ unterschiedlich behandelt. Jede Query deklariert ihre CycleStrategy (siehe Abschnitt 18.3).

// Beispiel: Zyklus über Handle-Typen ist kein Fehler:
// ResolveType(Node) → GetMemoryLayout(Node) → ResolveType(Node)  [über Shared[Node]]
// → HandleResolved-Strategie: Indirektion durchbrechen, Handle hat feste Größe

// Echter Fehler: direkte Größen-Rekursion:
// GetTypeSize(A) → GetTypeSize(B) → GetTypeSize(A)  [ohne Handle]
// → LayoutError-Strategie: Diagnostic + Fallback-Layout

Invariante 7 — Labels von Typparametern nur über db.query()

Handler die das TruthProfile eines generischen Typs berechnen, dürfen Labels von Typparametern nie direkt aus internen Datenstrukturen lesen. Der einzige erlaubte Weg ist db.query(GetTruthProfile { id }).

Warum: Nur über db.query() wird die Dependency automatisch im DepGraph registriert. Direkter Zugriff erzeugt keine Dependency — Änderungen am Typparameter invalidieren den Container dann nicht.

// VERBOTEN — keine Dependency entsteht:
@handles!GetTruthProfile
LabelSet getTruthProfile(GetTruthProfile q, CompilerDb* db) @nogc nothrow pure {
    auto paramLabels = q.typeParam.cachedLabels;  // direkt — VERBOTEN
}

// ERLAUBT — Dependency wird automatisch registriert:
@handles!GetTruthProfile
LabelSet getTruthProfile(GetTruthProfile q, CompilerDb* db) @nogc nothrow pure {
    auto paramLabels = db.query(GetTruthProfile { q.typeParamId });  // über db
}

Die TypeEngine exponiert daher keine Labels direkt über ihre ABI. GetTruthProfile ist der einzige Zugriffspunkt — für eigene Typen und für Typparameter gleichermassen.


18. Implementierungshinweise

18.1 @nogc, nothrow, pure durchhalten

Alle Derived Handler und der kritische Pfad in query() müssen diese drei Attribute tragen. Der Compiler erzwingt das statisch — ein Handler der dagegen verstößt ist ein Buildfehler:

// Alle Derived Handler müssen diese Signaturform haben:
@handles!SomeQuery
T handleSomeQuery(SomeQuery q, CompilerDb* db) @nogc nothrow pure { ... }

// query() selbst ist weakly pure — Mutation über Parameter (db) ist erlaubt:
T query(T, Q)(CompilerDb* db, Q q) @nogc nothrow pure
    if (isDerivedQuery!Q)
{ ... }

pure in D bedeutet hier weakly pure: Mutation über Parameter ist erlaubt, globaler Zustand ist verboten. Das passt exakt zu den Query-Invarianten.

Da @nogc keine stdlib HashMap erlaubt, nutzt man eine sortierte Arena-Liste mit binärer Suche, oder eine eigene offene Hash-Tabelle:

struct QueryCache {
    Arena  keyArena;
    Arena  entryArena;

    // Offene Hash-Tabelle — @nogc kompatibel:
    struct Bucket {
        QueryKey  key;
        QueryResult* entry;   // zeigt in entryArena
    }

    Bucket[] buckets;   // in Arena alloziert, feste Größe

    QueryResult* get(QueryKey key) @nogc {
        auto hash = hashKey(key);
        auto idx  = hash % buckets.length;
        // linear probing...
    }

    void put(QueryKey key, QueryResult entry) @nogc {
        // ...
    }
}

18.2 Serialisierung @nogc

Query-Ergebnisse müssen in resultArena serialisiert werden:

ubyte[] serialize(T)(T value, ref Arena arena) @nogc {
    // Für einfache Structs: direkte Memcopy
    static if (isSimpleStruct!T) {
        auto buf = arena.allocate(T.sizeof);
        *cast(T*)buf.ptr = value;
        return buf;
    }
    // Für komplexe Typen: manuell
    else { ... }
}

18.3 Zyklen-Erkennung und CycleStrategy

Zyklen werden über einen aktiven Query-Stack erkannt. Was beim Zyklus passiert, hängt von der CycleStrategy der betroffenen Query ab:

enum CycleStrategy {
    LayoutError,      // Zyklus = unendliche Größe = Fehler im Programm
    HandleResolved,   // Zyklus über Handle-Indirektion — kein Fehler
    Fixpoint,         // iterieren bis Konvergenz (Label-Inferenz)
    InternalError     // Compiler-Bug — Panic in Debug, Fallback in Release
}

// Pro Query-Typ deklariert:
template cycleStrategy(Q : GetMemoryLayout) { enum cycleStrategy = CycleStrategy.LayoutError; }
template cycleStrategy(Q : GetTypeSize)     { enum cycleStrategy = CycleStrategy.LayoutError; }
template cycleStrategy(Q : GetTypeAlignment){ enum cycleStrategy = CycleStrategy.LayoutError; }
template cycleStrategy(Q : ResolveType)     { enum cycleStrategy = CycleStrategy.HandleResolved; }
template cycleStrategy(Q : GetTruthProfile) { enum cycleStrategy = CycleStrategy.Fixpoint; }
template cycleStrategy(Q)                   { enum cycleStrategy = CycleStrategy.InternalError; }

LayoutErrorGetMemoryLayout, GetTypeSize, GetTypeAlignment:

Ein direkter Größenzyklus (A enthält B, B enthält A) bedeutet unendliche Größe — immer ein Fehler im Programm. Diagnostic mit Zykluspfad, dann Fallback-Layout zurückgeben damit die Compilation fortgesetzt werden kann:

FEHLER: Typ 'A' hat unendliche Größe.
        A enthält B (Zeile 1, Spalte 5)
        B enthält A (Zeile 2, Spalte 5)
        Hilfe: Verwende einen Handle-Typ wie [A] oder Shared[A]

HandleResolvedResolveType:

Ein Zyklus über [T], Shared[T] oder Weak[T] ist kein Fehler — Handle-Typen haben feste Größe unabhängig von T. Recovery: Typ als Handle-Indirektion markieren, Größe als Pointer-Größe annehmen, Zyklus durchbrechen.

FixpointGetTruthProfile und Label-Queries:

Label-Inferenz kann zyklisch sein wenn sich Labels gegenseitig beeinflussen. Recovery: Initialen Wert einsetzen, iterieren bis Fixpunkt oder Iterations-Limit erreicht. Bei Limit: Diagnostic "Label-Inferenz konvergiert nicht für Typ X".

InternalError — alle anderen Queries:

Ein Zyklus hier ist ein Bug im Compiler, kein User-Fehler. In Debug-Builds: Panic mit Zykluspfad. In Release-Builds: defaultValue!T zurückgeben und Diagnostic mit "interner Fehler" ausgeben.

T handleCycle(T, Q)(CompilerDb* db, Q q) @nogc nothrow {
    static if (cycleStrategy!Q == CycleStrategy.LayoutError) {
        emitCycleDiagnostic(db, q);
        return fallbackLayout!T;
    }
    else static if (cycleStrategy!Q == CycleStrategy.HandleResolved) {
        return resolveAsHandle!T(db, q);
    }
    else static if (cycleStrategy!Q == CycleStrategy.Fixpoint) {
        return runFixpoint!T(db, q);
    }
    else {
        debug assert(false, "Compiler-Bug: Zyklus in " ~ Q.stringof);
        emitInternalError(db, q);
        return defaultValue!T;
    }
}

18.4 Testing

Jede Query kann isoliert getestet werden:

unittest {
    // Mock-DB mit nur den nötigen Subsystemen:
    auto db = CompilerDb.mock(
        typeEngine: MockTypeEngine {
            types: [
                TypeId(1): TypeNode { kind: Primitive, name: "i32" }
            ]
        }
    );

    // Input setzen:
    db.setInput(FileContent { "test.jdl" }, "type Foo: struct { x: i32 }");

    // Query direkt testen:
    auto result = db.query!TypeNode(ResolveType { TypeId(1) });
    assert(result.kind == TypeKind.Primitive);
    assert(result.name == "i32");
}

Anhang A — Dateistruktur

jade/
├── compiler/
│   ├── compiler_db.d       ← CompilerDb, query(), dispatch(), setInput()
│   ├── queries.d           ← Alle Query-Structs (Input + Derived)
│   ├── query_cache.d       ← QueryCache Implementierung
│   ├── dep_graph.d         ← DepGraph Implementierung
│   ├── subsystems/
│   │   ├── parser.d        ← Parser + @handles für Parser-Queries
│   │   ├── type_engine.d   ← TypeEngine + @handles für Type-Queries
│   │   ├── resolver.d      ← NameResolver + @handles
│   │   └── jme.d           ← JME + @handles für Memory-Queries
│   └── tools/              ← Consumer — keine Subsysteme, keine Handler
│       ├── generator.d     ← Code Generator als Query-Consumer
│       ├── lsp_server.d    ← LSP-Server als Query-Consumer
│       └── doc_generator.d ← jade doc als Query-Consumer

Anhang B — Glossar

Begriff Bedeutung
Query Typisierte, idempotente Anfrage an die CompilerDb
Input Query Query ohne Handler — wird über setInput von außen gesetzt; jedes setInput erhöht currentRevision
Derived Query Query mit @handles-Handler — wird lazy aus anderen Queries berechnet; pure nothrow @nogc
CompilerDb Zentrale Kommunikationsschicht aller Subsysteme
QueryKey Eindeutiger Cache-Schlüssel für eine Query-Instanz
DepGraph Graph der Query-Abhängigkeiten; vorwärts (dependenciesOf) und rückwärts (dependentsOf)
Revision Globaler Zähler currentRevision — wird bei jedem setInput inkrementiert
changedAt Revision in der sich ein Query-Ergebnis zuletzt inhaltlich geändert hat
verifiedAt Revision in der zuletzt geprüft wurde ob ein Query-Ergebnis noch stimmt
Early Cutoff Abbruch der Revalidierung wenn sich das Ergebnis einer Zwischenquery nicht geändert hat
Lazy Revalidierung Überprüfung der Gültigkeit erst bei query(), nicht bei setInput
Durability Klassifikation von Input Queries nach Änderungshäufigkeit: Low, Medium, High
Cancellation Mechanismus um laufende Berechnungen bei neuen Edits abzubrechen
CycleStrategy Pro-Query deklarierte Behandlung zyklischer Abhängigkeiten: LayoutError, HandleResolved, Fixpoint, InternalError
Invalidierung (Legacy) eager-rekursives Markieren von Cache-Einträgen als veraltet — ersetzt durch Lazy Revalidierung
Subsystem Parser, TypeEngine, Resolver, JME — registrieren Handler, besitzen Arenen
Consumer Tool das die CompilerDb lesend nutzt ohne Handler zu registrieren (Generator, LSP, jade doc, ...)
resultArena Arena für Query-Ergebnisse die Subsystem-Grenzen überschreiten
Inkrementalität Nur geänderte Queries werden neu berechnet
DocNode AST-Knoten für Dokumentations-Kommentare — nur bei keepDocComments=true

Anhang C — Änderungsprotokoll

v0.5.0 (April 2026) — Salsa-inspirierte Erweiterungen

Neu: - Abschnitt 2.5: Input Queries vs. Derived Queries — fundamentale Zweiteilung - Abschnitt 7: Lazy Revalidierung mit Early Cutoff — ersetzt eager Invalidierung vollständig - Abschnitt 8: Durability — Kurzschluss-Optimierung für stabile Inputs - Abschnitt 9: Cancellation — Abbruch laufender Berechnungen bei neuen Edits - setInput-API in Abschnitt 4.1 - dependenciesOf und clearDependencies im DepGraph (Abschnitt 6.2) - Vier-Kategorien CycleStrategy in Abschnitt 18.3

Geändert: - QueryResult: version/validchangedAt/verifiedAt (Abschnitt 3.3) - CompilerDb: versioncurrentRevision, lastChangedByDurability hinzu (Abschnitt 4.1) - query(): Early-Cutoff-Algorithmus (Abschnitt 4.2) - Abschnitt 3.4: READ/CHECK/COMPUTE → Input/Derived - Abschnitt 4.4: eager invalidate() entfällt, Verweis auf Abschnitt 7 - Abschnitt 5.1: @handles gilt nur für Derived Queries - Abschnitt 5.2: parseFile liest FileContent-Input statt direkt Datei - Abschnitt 12.2: LSP didChange nutzt setInput + Cancellation - Invariante 3: Seiteneffektfreiheit gilt nur für Derived Queries - Invariante 6: Zyklen kategoriebasiert statt pauschal verboten - Query-Katalog (Abschnitt 15): Input Queries als eigene Sektion, Nummerierung korrigiert - Abschnittsnummerierung: 8–16 (v0.4) → 10–18 (v0.5)

v0.4.0 (März 2026) — Initiale Spec

Erstversion mit Query-System, @handles-UDA, Arena-Integration, Consumer-Modell, Fehlerbehandlung, Invarianten.


Jade Compiler Subsystem API Spec v0.5.0 — April 2026