etdl_compiler/lib.rs
1//! Compiler pipeline for the Event Tree Definition Language (ETDL).
2//!
3//! Validates `.etdl` documents, resolves fault tree top-event probabilities
4//! ([IEC 61025:2006](https://github.com/ETDL-lang/etdl-specification)), and
5//! generates service-local code. Reliability — event trees (IEC 62502),
6//! fault trees (IEC 61025), retry policies, SLAs — becomes a build-time,
7//! machine-checked artifact instead of scattered runtime guesses.
8//!
9//! # Pipeline
10//!
11//! 1. [`validate::validate_document`] — structural and semantic diagnostics (E-1xx,
12//! V-1xx..V-5xx, W-4xx)
13//! 2. [`fault_tree::resolve_fault_trees`] — exact top-event probability evaluation
14//! (AND/OR/NOT/XOR/VOTING gates, exponential failure model)
15//! 3. [`typeck`] — ECEL condition type-checking against AsyncAPI schemas
16//! 4. [`codegen::CodeGenerator`] — backend trait; [`codegen::RustCodeGenerator`]
17//! emits async handlers with embedded probabilities, retry policies, and
18//! `etdl-core` instrumentation
19//!
20//! # Example
21//!
22//! ```no_run
23//! use etdl_compiler::Compiler;
24//! use etdl_parser::{parse_document_from_file, load_asyncapi_imports};
25//! use std::path::Path;
26//!
27//! let base = Path::new(".");
28//! let doc = parse_document_from_file(&base.join("order-fulfillment.etdl"))?;
29//! let registry = load_asyncapi_imports(&doc, base)?;
30//! let result = Compiler::new().compile(&doc, ®istry);
31//! assert!(result.diagnostics.iter().all(|d| !d.is_error()));
32//! assert!(result.rust_output.is_some());
33//! # Ok::<(), String>(())
34//! ```
35
36use etdl_parser::ast::EtlDocument;
37use etdl_parser::asyncapi::AsyncApiRegistry;
38
39pub mod codegen;
40pub mod extension;
41pub mod fault_tree;
42#[cfg(feature = "reliability")]
43pub mod reliability;
44pub mod stdlib;
45pub mod tree_event;
46mod typeck;
47pub mod validate;
48
49pub use codegen::{CodeGenerator, GeneratedFile, RustCodeGenerator};
50pub use extension::{EtdlExtension, ExtensionContext, ExtensionRegistry};
51pub use validate::Diagnostic;
52
53pub struct Compiler {
54 pub rust_codegen: RustCodeGenerator,
55 /// Resolves declared `libraries:` (standard/domain/optional/user). The
56 /// built-in standard library and base_dir-relative user libraries
57 /// resolve automatically; add optional-library search paths with
58 /// [`Compiler::with_library_search_path`].
59 pub library_resolver: stdlib::LibraryResolver,
60 /// Extensions registered in addition to the built-in ones (the
61 /// Reliability and Tree Event supplements, handled internally by
62 /// `run_extensions` exactly as before — unaffected by this field).
63 /// This is the entry point a non-core supplement (core spec Section
64 /// 11.4/11.5 — e.g. a third-party `etdl.chain` implementation)
65 /// registers itself through via [`Compiler::with_extension`], so its
66 /// `validate`/`process` (core spec Section 11.3) actually run during
67 /// [`Compiler::compile`]/[`Compiler::validate`], not just something a
68 /// third party could theoretically implement.
69 extensions: Vec<Box<dyn EtdlExtension>>,
70}
71
72/// A complete compilation result including optional reliability provenance.
73#[derive(Debug, Clone)]
74pub struct CompilationResult {
75 pub diagnostics: Vec<Diagnostic>,
76 pub rust_output: Option<String>,
77 /// Reliability build manifest, present when the document declares the
78 /// reliability supplement and external sources were resolved.
79 pub build_manifest: Option<serde_json::Value>,
80 /// Identity of every library actually resolved for this build (name,
81 /// version, built-in/optional/user), independent of the `reliability`
82 /// feature — a document using `libraries:` gets this without needing
83 /// any optional feature enabled.
84 pub resolved_libraries: Vec<stdlib::LibraryProvenance>,
85}
86
87/// Result of compiling for one arbitrary target generator
88/// ([`Compiler::compile_target`]/[`Compiler::compile_target_with_base`]) —
89/// the target-neutral counterpart to [`CompilationResult`], which stays
90/// Rust-specific (`rust_output: Option<String>`) for backward compatibility.
91#[derive(Debug, Clone)]
92pub struct TargetCompilationResult {
93 pub diagnostics: Vec<Diagnostic>,
94 /// The generated files, present iff generation succeeded with no
95 /// upstream errors. Empty output (a generator returning zero files) is
96 /// treated the same as `None`, matching `CompilationResult::rust_output`.
97 pub files: Option<Vec<codegen::GeneratedFile>>,
98 pub build_manifest: Option<serde_json::Value>,
99 pub resolved_libraries: Vec<stdlib::LibraryProvenance>,
100}
101
102/// The target-neutral part of the pipeline (library expansion, validation,
103/// extension processing, fault-tree probability resolution) — computed once
104/// and shared by every target's compilation, so no target implementation
105/// (Rust or otherwise) re-runs parsing, semantic validation, or fault-tree
106/// evaluation. See `docs/architecture/targets.md`.
107struct Prepared {
108 expanded_doc: EtlDocument,
109 fault_tree_probs: std::collections::BTreeMap<String, f64>,
110 diagnostics: Vec<Diagnostic>,
111 build_manifest: Option<serde_json::Value>,
112 resolved_libraries: Vec<stdlib::LibraryProvenance>,
113}
114
115impl Compiler {
116 pub fn new() -> Self {
117 Compiler {
118 rust_codegen: RustCodeGenerator::new(),
119 library_resolver: stdlib::LibraryResolver::new(),
120 extensions: Vec::new(),
121 }
122 }
123
124 /// Add a search directory for optional (non-`std.*`) libraries. Checked
125 /// in the order added; never consulted for names under the reserved
126 /// `std.` namespace.
127 pub fn with_library_search_path(mut self, path: impl Into<std::path::PathBuf>) -> Self {
128 self.library_resolver = self.library_resolver.with_search_path(path);
129 self
130 }
131
132 /// Register an additional supplement extension (core spec Section 11.5:
133 /// "Defining a New Supplement"). Its `validate`/`process` (core spec
134 /// Section 11.3) run during [`Compiler::validate`]/[`Compiler::compile`]
135 /// exactly like the built-in Reliability/Tree Event extensions do,
136 /// gated the same way: only for a document that actually declares the
137 /// extension's `id()` under `supplements:` (core spec Section 5.1.1).
138 /// This does not replace or reorder the built-in extensions, which
139 /// [`Compiler::run_extensions`] continues to handle exactly as before —
140 /// it adds a place for anything else to plug in.
141 pub fn with_extension(mut self, extension: Box<dyn EtdlExtension>) -> Self {
142 self.extensions.push(extension);
143 self
144 }
145
146 /// Run the full validation pipeline (semantic checks, fault-tree
147 /// resolution, probability validation, ECEL type checking) without
148 /// generating any code.
149 pub fn validate(
150 &self,
151 doc: &EtlDocument,
152 asyncapi_registry: &AsyncApiRegistry,
153 ) -> Vec<Diagnostic> {
154 self.validate_with_base(doc, asyncapi_registry, std::path::Path::new("."))
155 }
156
157 /// Validate, resolving external reliability sources relative to `base_dir`.
158 pub fn validate_with_base(
159 &self,
160 doc: &EtlDocument,
161 asyncapi_registry: &AsyncApiRegistry,
162 base_dir: &std::path::Path,
163 ) -> Vec<Diagnostic> {
164 let mut diagnostics = Vec::new();
165
166 // Resolve `libraries:` and splice referenced definitions into the
167 // fault trees that use them BEFORE structural validation runs, so a
168 // qualified library reference (e.g. `std.events.NetworkTimeout`)
169 // validates exactly like any other basic-event id. The original
170 // `doc` is never mutated.
171 let (expanded_doc, resolved_libs, lib_errors) =
172 stdlib::expand_libraries(doc, base_dir, &self.library_resolver);
173 validate::validate_libraries(doc, &lib_errors, &mut diagnostics);
174 let doc = &expanded_doc;
175 let _ = &resolved_libs;
176
177 let registered_extension_ids: Vec<&str> =
178 self.extensions.iter().map(|e| e.id()).collect();
179 validate::validate_document_with_extensions(
180 doc,
181 asyncapi_registry,
182 ®istered_extension_ids,
183 &mut diagnostics,
184 );
185
186 // The Generic Tree Event Supplement is structural-only (no fault-tree
187 // overrides to feed forward), so it is validated directly here
188 // rather than through `run_extensions`'s override-collecting path —
189 // purely additive; nothing about the reliability extension's own
190 // call below changed.
191 let (_trees, tree_diagnostics) = tree_event::parse_and_validate_trees(doc);
192 diagnostics.extend(tree_diagnostics);
193
194 if diagnostics.iter().any(|d| d.is_error()) {
195 return diagnostics;
196 }
197
198 // Run registered extensions' semantic processing (e.g. external
199 // probability resolution) so that values they supply feed evaluation.
200 let (overrides, _manifest) = self.run_extensions(doc, base_dir, &mut diagnostics);
201
202 let fault_tree_probs =
203 fault_tree::resolve_fault_trees_with_overrides(doc, &overrides, &mut diagnostics);
204 let resolved_probabilities =
205 validate::resolve_probability_links(doc, &fault_tree_probs, &mut diagnostics);
206
207 validate::validate_probability_sums(doc, &resolved_probabilities, &mut diagnostics);
208
209 if diagnostics.iter().any(|d| d.is_error()) {
210 return diagnostics;
211 }
212
213 typeck::type_check_conditions(doc, asyncapi_registry, &mut diagnostics);
214
215 diagnostics
216 }
217
218 pub fn compile(
219 &self,
220 doc: &EtlDocument,
221 asyncapi_registry: &AsyncApiRegistry,
222 ) -> CompilationResult {
223 self.compile_with_base(doc, asyncapi_registry, std::path::Path::new("."))
224 }
225
226 /// Compile with a base directory for resolving relative reliability
227 /// artifact paths.
228 pub fn compile_with_base(
229 &self,
230 doc: &EtlDocument,
231 asyncapi_registry: &AsyncApiRegistry,
232 base_dir: &std::path::Path,
233 ) -> CompilationResult {
234 let prepared = self.prepare(doc, asyncapi_registry, base_dir);
235 if prepared.diagnostics.iter().any(|d| d.is_error()) {
236 return CompilationResult {
237 diagnostics: prepared.diagnostics,
238 rust_output: None,
239 build_manifest: prepared.build_manifest,
240 resolved_libraries: prepared.resolved_libraries,
241 };
242 }
243
244 let mut diagnostics = prepared.diagnostics;
245 // "generated" is a placeholder stem: compile_with_base predates
246 // per-file naming (callers only ever read `rust_output`'s string
247 // content, never a filename derived from it), so nothing observes
248 // this value — it exists only to satisfy generate_all's signature,
249 // which every target (not just this one) now takes a stem through.
250 let gen_result = self.rust_codegen.generate_all(
251 &prepared.expanded_doc,
252 &prepared.fault_tree_probs,
253 asyncapi_registry,
254 "generated",
255 &mut diagnostics,
256 );
257 let rust_output = gen_result
258 .ok()
259 .and_then(|files| files.into_iter().next())
260 .map(|f| f.contents)
261 .filter(|s| !s.is_empty());
262
263 CompilationResult {
264 diagnostics,
265 rust_output,
266 build_manifest: prepared.build_manifest,
267 resolved_libraries: prepared.resolved_libraries,
268 }
269 }
270
271 /// Compile for an arbitrary registered target (see `etdl-cli`'s target
272 /// registry) rather than the built-in Rust target specifically. `stem`
273 /// is the input document's filename without extension, used for output
274 /// naming exactly like `compile_with_base`'s Rust path already does.
275 pub fn compile_target(
276 &self,
277 doc: &EtlDocument,
278 asyncapi_registry: &AsyncApiRegistry,
279 generator: &dyn CodeGenerator,
280 stem: &str,
281 ) -> TargetCompilationResult {
282 self.compile_target_with_base(doc, asyncapi_registry, std::path::Path::new("."), generator, stem)
283 }
284
285 /// [`Compiler::compile_target`] with a base directory for resolving
286 /// relative reliability artifact paths.
287 pub fn compile_target_with_base(
288 &self,
289 doc: &EtlDocument,
290 asyncapi_registry: &AsyncApiRegistry,
291 base_dir: &std::path::Path,
292 generator: &dyn CodeGenerator,
293 stem: &str,
294 ) -> TargetCompilationResult {
295 let prepared = self.prepare(doc, asyncapi_registry, base_dir);
296 if prepared.diagnostics.iter().any(|d| d.is_error()) {
297 return TargetCompilationResult {
298 diagnostics: prepared.diagnostics,
299 files: None,
300 build_manifest: prepared.build_manifest,
301 resolved_libraries: prepared.resolved_libraries,
302 };
303 }
304
305 let mut diagnostics = prepared.diagnostics;
306 let gen_result = generator.generate_all(
307 &prepared.expanded_doc,
308 &prepared.fault_tree_probs,
309 asyncapi_registry,
310 stem,
311 &mut diagnostics,
312 );
313 let files = gen_result.ok().filter(|files| !files.is_empty());
314
315 TargetCompilationResult {
316 diagnostics,
317 files,
318 build_manifest: prepared.build_manifest,
319 resolved_libraries: prepared.resolved_libraries,
320 }
321 }
322
323 /// Shared pipeline for every target: library expansion, validation,
324 /// extension processing, fault-tree probability resolution. Faithfully
325 /// mirrors what `compile_with_base` always did inline (including
326 /// `validate_with_base`'s own idempotent re-expansion of `libraries:` —
327 /// see its doc comment) — extracted here, unchanged, so a second target
328 /// can share it instead of duplicating it.
329 fn prepare(
330 &self,
331 doc: &EtlDocument,
332 asyncapi_registry: &AsyncApiRegistry,
333 base_dir: &std::path::Path,
334 ) -> Prepared {
335 let (expanded_doc, resolved_libs, _lib_errors) =
336 stdlib::expand_libraries(doc, base_dir, &self.library_resolver);
337 let resolved_libraries: Vec<stdlib::LibraryProvenance> =
338 resolved_libs.iter().map(|l| l.provenance()).collect();
339
340 let mut diagnostics = self.validate_with_base(&expanded_doc, asyncapi_registry, base_dir);
341 let has_errors = diagnostics.iter().any(|d| d.is_error());
342 if has_errors {
343 return Prepared {
344 expanded_doc,
345 fault_tree_probs: std::collections::BTreeMap::new(),
346 diagnostics,
347 build_manifest: None,
348 resolved_libraries,
349 };
350 }
351
352 // Extensions: resolve external probability sources to deterministic
353 // scalars BEFORE fault-tree evaluation. Preserves the build-time
354 // resolution model; nothing is resolved at runtime.
355 let (overrides, manifest) = self.run_extensions(&expanded_doc, base_dir, &mut diagnostics);
356 let build_manifest = manifest.as_ref().and_then(|m| serde_json::to_value(m).ok());
357 let fault_tree_probs = fault_tree::resolve_fault_trees_with_overrides(
358 &expanded_doc,
359 &overrides,
360 &mut diagnostics,
361 );
362
363 Prepared {
364 expanded_doc,
365 fault_tree_probs,
366 diagnostics,
367 build_manifest,
368 resolved_libraries,
369 }
370 }
371
372 /// Run registered extensions' semantic processing.
373 ///
374 /// Each extension's `process` step may resolve external values (e.g.
375 /// probabilities). The returned map is the aggregated basic-event
376 /// probability overrides feeding the existing fault-tree evaluator. With
377 /// the `reliability` feature disabled, this returns an empty override map.
378 fn run_extensions(
379 &self,
380 doc: &EtlDocument,
381 base_dir: &std::path::Path,
382 diagnostics: &mut Vec<Diagnostic>,
383 ) -> (fault_tree::BasicEventOverrides, Option<serde_json::Value>) {
384 // Always `mut`: with the `reliability` feature disabled there is no
385 // built-in resolver to populate it below, but a generically
386 // registered extension (`Compiler::with_extension`) still can,
387 // regardless of that feature.
388 let mut overrides = fault_tree::BasicEventOverrides::new();
389 #[cfg(feature = "reliability")]
390 let manifest: Option<serde_json::Value> = {
391 let (resolved_events, m) = reliability::resolve_reliability(doc, base_dir, diagnostics);
392 overrides.extend(
393 resolved_events
394 .iter()
395 .map(|r| (r.override_key(), r.resolved.value)),
396 );
397 m.as_ref().and_then(|m| serde_json::to_value(m).ok())
398 };
399 // The built-in reliability resolver is compiled out without the
400 // `reliability` feature; `doc`/`base_dir`/`diagnostics` remain real
401 // parameters regardless, used below by any generically registered
402 // extension (`Compiler::with_extension`).
403 #[cfg(not(feature = "reliability"))]
404 let manifest: Option<serde_json::Value> = None;
405
406 // Additionally registered extensions (`Compiler::with_extension`):
407 // run validate() then process() for each one the document actually
408 // declares under `supplements:` (the same declare-to-opt-in gate
409 // the built-in extensions already use), merging any basic-event
410 // overrides they resolve. Their manifests, if any, are not folded
411 // into the single `manifest` value above — a caller wanting a
412 // registered extension's own output reads it from that extension's
413 // `ExtensionResult` directly in a caller-side integration, since
414 // this method's `Option<serde_json::Value>` return shape predates
415 // there being more than one extension.
416 for extension in &self.extensions {
417 if !crate::validate::declares_supplement(doc, extension.id()) {
418 continue;
419 }
420 let context = crate::extension::ExtensionContext::new(doc, base_dir);
421 extension.validate(doc, &context, diagnostics);
422 if diagnostics.iter().any(|d| d.is_error()) {
423 continue;
424 }
425 let result = extension.process(doc, &context, diagnostics);
426 overrides.extend(result.basic_event_overrides());
427 }
428
429 (overrides, manifest)
430 }
431}
432
433impl Default for Compiler {
434 fn default() -> Self {
435 Self::new()
436 }
437}