Skip to main content

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, &registry);
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            &registered_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}