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¶
- Motivation und Designziele
- Kernkonzepte
- Query — Definition und Anatomie
- CompilerDb — die zentrale Datenbank
- Subsystem-Registrierung
- Dependency-Tracking
- Lazy Revalidierung — Early Cutoff
- Durability
- Cancellation
- Arena-Integration
- Fehlerbehandlung
- Emergente Tools — Consumer der Query API
- jade doc — Query-basierter Dokumentationsgenerator
- Vollständiges Beispiel — End-to-End
- Query-Katalog — Alle definierten Queries
- Erweiterung — Neue Queries hinzufügen
- Invarianten und Regeln
- 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:
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.
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:
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.putaufgerufen 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
clearDependenciesbereinigt.
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,GetDefinitionfür sichere Umbenennungen - Build-System-Integration — nutzt
ModuleList(Input) undGetExportsfü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.
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
DocNodeist noch nicht spezifiziert jade doc --generateals 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; }
LayoutError — GetMemoryLayout, 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]
HandleResolved — ResolveType:
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.
Fixpoint — GetTruthProfile 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/valid → changedAt/verifiedAt (Abschnitt 3.3)
- CompilerDb: version → currentRevision, 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