Skip to main content

helm_schema_ir/fragment_eval/
domain.rs

1//! The `Guarded<AbstractFragment>` domain: an abstract rendered document.
2//!
3//! A fragment is the shape a template *renders*, with template control flow
4//! preserved as guard trees instead of being flattened into per-row guard
5//! vectors. Guards live on branch nodes: every alternative of a
6//! [`Guarded`] value carries the [`PathCondition`] under which that arm
7//! materializes, and root-to-leaf condition chains replace the current
8//! pipeline's ambient guard stacks.
9
10use std::collections::BTreeSet;
11use std::rc::Rc;
12
13use crate::{ContractProvenance, ResourceRef, ValueKind};
14use helm_schema_core::Predicate;
15
16/// Render-site facts resolved at evaluation time: the manifest resource
17/// whose span contains the site, that resource span's path prefix (List
18/// envelope items rebase their emitted paths below `items[*]`), and the
19/// site's source provenance. Shared by every row one hole produces.
20#[derive(Clone, Debug, Default, PartialEq, Eq)]
21pub struct SiteFacts {
22    /// The resource containing this site, when its document declares one.
23    pub resource: Option<ResourceRef>,
24    /// Path segments the containing resource span strips from emitted paths.
25    pub path_prefix: Vec<String>,
26    /// The site's own source span, when the template has a source path.
27    pub provenance: Option<ContractProvenance>,
28}
29
30/// The condition under which a guarded arm materializes.
31///
32/// This is exactly the existing typed predicate lattice; the fragment domain
33/// deliberately introduces no parallel guard representation.
34pub type PathCondition = Predicate;
35
36/// A guarded alternative set: each arm materializes when its condition
37/// holds. An arm with [`Predicate::True`] is unconditional; several arms are
38/// candidates preserved side by side (branch alternatives, merge
39/// contributions, and ambiguous evaluations all land here — arms are
40/// *contribution candidates*, and mutual exclusivity is expressed only by
41/// contradictory conditions).
42#[derive(Clone, Debug, PartialEq, Eq)]
43pub struct Guarded<T> {
44    /// The guarded arms in document order.
45    pub arms: Vec<(PathCondition, T)>,
46}
47
48impl<T> Default for Guarded<T> {
49    fn default() -> Self {
50        Self::empty()
51    }
52}
53
54impl<T> Guarded<T> {
55    /// A guarded value with no arms (nothing renders).
56    #[must_use]
57    pub fn empty() -> Self {
58        Self { arms: Vec::new() }
59    }
60
61    /// A single unconditional arm.
62    #[must_use]
63    pub fn unconditional(node: T) -> Self {
64        Self {
65            arms: vec![(Predicate::True, node)],
66        }
67    }
68
69    /// A single arm under `condition`.
70    #[must_use]
71    pub fn conditional(condition: PathCondition, node: T) -> Self {
72        Self {
73            arms: vec![(condition, node)],
74        }
75    }
76
77    /// Whether this guarded value has no arms.
78    #[must_use]
79    pub fn is_empty(&self) -> bool {
80        self.arms.is_empty()
81    }
82
83    /// Conjoin `condition` onto every arm (used when a control region
84    /// dissolves into the surrounding container: each contribution keeps its
85    /// own arm conditions and gains the region branch condition).
86    pub fn guard_all(&mut self, condition: &PathCondition) {
87        if condition.is_trivial() && *condition == Predicate::True {
88            return;
89        }
90        for (arm_condition, _) in &mut self.arms {
91            *arm_condition = and_conditions(condition.clone(), arm_condition.clone());
92        }
93    }
94
95    /// Append all arms of `other`.
96    pub fn extend(&mut self, other: Self) {
97        self.arms.extend(other.arms);
98    }
99}
100
101/// Conjoin two conditions, flattening nested `And`s and treating
102/// [`Predicate::True`] as identity. Operand order is preserved (outer
103/// condition first) so lowered guard stacks read root-to-leaf.
104#[must_use]
105pub fn and_conditions(outer: PathCondition, inner: PathCondition) -> PathCondition {
106    let mut parts = Vec::new();
107    for condition in [outer, inner] {
108        match condition {
109            Predicate::True => {}
110            Predicate::And(inner_parts) => {
111                for part in inner_parts {
112                    if !parts.contains(&part) {
113                        parts.push(part);
114                    }
115                }
116            }
117            other => {
118                if !parts.contains(&other) {
119                    parts.push(other);
120                }
121            }
122        }
123    }
124    Predicate::all(parts)
125}
126
127/// One node of the abstract rendered document.
128#[derive(Clone, Debug, PartialEq, Eq)]
129#[expect(
130    clippy::large_enum_variant,
131    reason = "boxing rendered fragments would add allocation and pointer chasing in the interpreter hot path"
132)]
133pub enum AbstractFragment {
134    /// A YAML mapping.
135    Mapping(Mapping),
136    /// A YAML sequence.
137    Sequence(Sequence),
138    /// A scalar value, possibly partial (literal text interleaved with
139    /// template holes).
140    Scalar(AbstractString),
141    /// A `.Values` path rendered whole at this position.
142    Splice(Splice),
143    /// A position whose rendered content is unknown; `taint` conservatively
144    /// names the `.Values` paths that flowed into it.
145    Opaque(Opaque),
146}
147
148/// A YAML mapping: entries in document order, one entry per key (repeated
149/// keys across branches merge their guarded value arms).
150#[derive(Clone, Debug, Default, PartialEq, Eq)]
151pub struct Mapping {
152    /// The mapping entries in first-seen document order.
153    pub entries: Vec<MappingEntry>,
154}
155
156/// One mapping entry: the key and its guarded value alternatives.
157#[derive(Clone, Debug, PartialEq, Eq)]
158pub struct MappingEntry {
159    /// The entry key.
160    pub key: EntryKey,
161    /// The guarded value alternatives for this key.
162    pub value: Guarded<AbstractFragment>,
163}
164
165/// A mapping key: literal text, or a templated key whose text is only
166/// partially known.
167#[derive(Clone, Debug, PartialEq, Eq)]
168pub enum EntryKey {
169    /// A plain literal key (unquoted).
170    Literal(String),
171    /// A templated key. Projections attribute the entry's value at the
172    /// structural dynamic-member path without guessing its rendered key; the
173    /// key's own splices are recorded as pathless reads at the eval site,
174    /// where the ambient range/branch predicates are still known.
175    Dynamic(AbstractString),
176}
177
178/// A YAML sequence: guarded items in document order.
179#[derive(Clone, Debug, Default, PartialEq, Eq)]
180pub struct Sequence {
181    /// The sequence items in document order.
182    pub items: Vec<Guarded<AbstractFragment>>,
183}
184
185/// A scalar modeled as ordered parts: literal text runs, whole-value
186/// splices, and opaque taint. Within one arm the parts list is the ordered
187/// *set of contributions* to the rendered text: alternation inside a single
188/// hole (a `Choice` value) degrades to multiple parts, which projections
189/// treat identically (every contribution attributes at the scalar's
190/// position).
191#[derive(Clone, Debug, Default, PartialEq, Eq)]
192pub struct AbstractString {
193    /// The scalar parts in render order.
194    pub parts: Vec<StringPart>,
195    /// The scalar is a render-suppressed text blob (a block scalar body):
196    /// contained splices influence its text but are not sink-typed at the
197    /// scalar's document position, so projections keep them pathless.
198    pub suppressed: bool,
199}
200
201impl AbstractString {
202    /// A scalar consisting of one literal text run.
203    #[must_use]
204    pub fn literal(text: impl Into<String>) -> Self {
205        Self {
206            parts: vec![StringPart::Text([text.into()].into_iter().collect())],
207            suppressed: false,
208        }
209    }
210}
211
212/// One part of an [`AbstractString`].
213#[derive(Clone, Debug, PartialEq, Eq)]
214#[expect(
215    clippy::large_enum_variant,
216    reason = "string parts are traversed in hot projection loops and benefit from inline storage"
217)]
218pub enum StringPart {
219    /// Literal text alternatives. A singleton set is plain source text;
220    /// larger sets arise when a hole statically evaluates to a finite string
221    /// set (branch literals, `printf` over string sets, …).
222    Text(BTreeSet<String>),
223    /// A `.Values` path rendered into the scalar.
224    Splice(Splice),
225    /// Unknown rendered text conservatively attributed to these `.Values`
226    /// paths.
227    Taint(TaintPart),
228}
229
230/// Unknown rendered text inside a scalar with its influencing paths.
231#[derive(Clone, Debug, Default, PartialEq, Eq)]
232pub struct TaintPart {
233    /// The `.Values` paths that flowed into the unknown text.
234    pub paths: BTreeSet<String>,
235    /// Exact structure serialized into this text, retained so a later
236    /// `fromJson` can recover the value shape across a helper boundary.
237    pub(crate) structured_value: Option<crate::abstract_value::AbstractValue>,
238    /// Whether `structured_value` was serialized as JSON at this boundary.
239    pub(crate) json_serialized: bool,
240    /// The render site the taint was observed at.
241    pub site: Option<Rc<SiteFacts>>,
242    /// Helper-body source sites the taint was derived through (spliced
243    /// summary content keeps its body sites here).
244    pub provenance: Vec<ContractProvenance>,
245}
246
247impl TaintPart {
248    /// Taint with no resolved site (stamped later by the interpreter).
249    #[must_use]
250    pub fn new(paths: BTreeSet<String>) -> Self {
251        Self {
252            paths,
253            structured_value: None,
254            json_serialized: false,
255            site: None,
256            provenance: Vec::new(),
257        }
258    }
259
260    pub(crate) fn from_json_serialized(value: crate::abstract_value::AbstractValue) -> Self {
261        Self {
262            paths: value.fragment_rendered_paths(),
263            structured_value: Some(value),
264            json_serialized: true,
265            site: None,
266            provenance: Vec::new(),
267        }
268    }
269}
270
271/// A `.Values` path rendered at a fragment position.
272#[derive(Clone, Debug, PartialEq, Eq)]
273pub struct Splice {
274    /// The dotted `.Values` path (never empty).
275    pub values_path: String,
276    /// Whether the path renders a whole scalar, part of a scalar, or a YAML
277    /// fragment at this position.
278    pub kind: ValueKind,
279    /// Render-site semantics carried with the splice.
280    pub meta: SpliceMeta,
281}
282
283impl Splice {
284    /// A plain scalar splice with default meta.
285    #[must_use]
286    pub fn scalar(values_path: impl Into<String>) -> Self {
287        Self {
288            values_path: values_path.into(),
289            kind: ValueKind::Scalar,
290            meta: SpliceMeta::default(),
291        }
292    }
293}
294
295/// Splice metadata: defaultedness, encoding, and source provenance.
296///
297/// Deliberately *no* predicates here — helper-internal branch conditions
298/// lower into [`Guarded`] arms so the guard structure stays in the tree.
299#[derive(Clone, Debug, Default, PartialEq, Eq)]
300pub struct SpliceMeta {
301    /// The render site substitutes a fallback when the path is empty/nil
302    /// (`default`, chart-level `set … default` mutations).
303    pub defaulted: bool,
304    /// The rendered text is an encoded transform of the value (`b64enc`),
305    /// so the sink schema does not constrain the value's shape.
306    pub encoded: bool,
307    /// The rendered text is a total stringification of the value (`quote`,
308    /// `toString`, `join`): any input type renders, so the sink neither
309    /// constrains nor reveals the input shape.
310    pub shape_erased: bool,
311    /// The stringification is Sprig `quote`/`squote`, which SKIP nil
312    /// operands: a missing or null source renders an explicit YAML null
313    /// into the sink, so provider-required slots still reject absence.
314    pub nil_omitted: bool,
315    /// The rendered fragment is the result of `toYaml`: every input kind can
316    /// be serialized, but its placement can still require sequence shape.
317    pub yaml_serialized: bool,
318    /// A string-consuming transform (`trunc`, `b64enc`, a dynamic `printf`
319    /// format) shaped the rendered text: rendering fails for non-string
320    /// values, so this splice's row binds a string contract under its own
321    /// conditions.
322    pub string_contract: bool,
323    /// The splice renders JSON text whose decoded value preserves this input identity.
324    pub json_serialized: bool,
325    /// The splice's runtime identity was recovered through JSON decoding.
326    pub json_decoded: bool,
327    /// Lexical divergences between the raw string and the rendered value
328    /// (`replace`/split-prefix chains, trim affixes); lexical captures
329    /// project their language through them.
330    pub lexical_escapes: BTreeSet<crate::helper_meta::LexicalEscape>,
331    /// The rendered text is one separator-delimited segment of the source
332    /// string (`regexSplit ":" . -1 | last`): sinks constrain that segment,
333    /// and raw-identity consumers (quoted-token claims, parser preimages)
334    /// must not read the splice as the raw value.
335    pub split_segment: Option<helm_schema_core::SplitSegmentUse>,
336    /// Set when the splice renders one layer of an ordered `merge`.
337    pub merge_layers: Option<helm_schema_core::MergeLayersUse>,
338    /// The splice renders the collection's RANGE KEY, not its value: sinks
339    /// constrain the key domain (a string-only slot excludes the integer
340    /// keys of a non-empty list lane), and raw-identity consumers must not
341    /// read it as the collection's value.
342    pub range_key: bool,
343    /// Literal member keys a guard-scoped `omit` may remove from the
344    /// rendered map, with the sound RETAIN guards under which each key
345    /// certainly survives.
346    pub omitted_members: std::collections::BTreeMap<String, Vec<helm_schema_core::Guard>>,
347    /// The slot renders fresh text DERIVED from the value
348    /// (`include … | sha256sum`): the sink observes neither the value nor
349    /// its serialization, so the row abstains from slot typing and from
350    /// path-wide serialization claims while keeping branch tolerance.
351    pub digest: bool,
352    /// The spliced value flowed through a Sprig `merge` call as a DIRECT
353    /// operand: the operand's strict map contract rides its own fail
354    /// implication, so the row never rejects a Helm-falsy input at the base.
355    pub merge_operand: bool,
356    /// Helper-body source sites this splice was derived through.
357    pub provenance: Vec<ContractProvenance>,
358    /// The render site the splice materializes at.
359    pub site: Option<Rc<SiteFacts>>,
360}
361
362/// An opaque fragment position: content unknown, influence preserved.
363#[derive(Clone, Debug, PartialEq, Eq)]
364pub struct Opaque {
365    /// The `.Values` paths that flowed into the unknown content.
366    pub taint: BTreeSet<String>,
367    /// The hole kind the opaque content renders as (scalar holes taint as
368    /// scalars, fragment holes as fragments).
369    pub kind: ValueKind,
370    /// The render site the opaque content was observed at.
371    pub site: Option<Rc<SiteFacts>>,
372    /// Helper-body source sites the content was derived through (spliced
373    /// summary content keeps its body sites here).
374    pub provenance: Vec<ContractProvenance>,
375}
376
377impl Default for Opaque {
378    fn default() -> Self {
379        Self {
380            taint: BTreeSet::new(),
381            kind: ValueKind::Scalar,
382            site: None,
383            provenance: Vec::new(),
384        }
385    }
386}
387
388/// Stamp `site` onto every row-producing node below `guarded` that has no
389/// site yet (nested-file content arrives already stamped by its own
390/// interpreter and keeps its facts).
391pub fn stamp_fragment_sites(guarded: &mut Guarded<AbstractFragment>, site: Option<&Rc<SiteFacts>>) {
392    if site.is_none() {
393        return;
394    }
395    for (_, node) in &mut guarded.arms {
396        stamp_node_sites(node, site);
397    }
398}
399
400fn stamp_node_sites(node: &mut AbstractFragment, site: Option<&Rc<SiteFacts>>) {
401    match node {
402        AbstractFragment::Mapping(mapping) => {
403            for entry in &mut mapping.entries {
404                stamp_fragment_sites(&mut entry.value, site);
405            }
406        }
407        AbstractFragment::Sequence(sequence) => {
408            for item in &mut sequence.items {
409                stamp_fragment_sites(item, site);
410            }
411        }
412        AbstractFragment::Scalar(scalar) => stamp_part_sites(&mut scalar.parts, site),
413        AbstractFragment::Splice(splice) => {
414            if splice.meta.site.is_none() {
415                splice.meta.site = site.cloned();
416            }
417        }
418        AbstractFragment::Opaque(opaque) => {
419            if opaque.site.is_none() {
420                opaque.site = site.cloned();
421            }
422        }
423    }
424}
425
426/// Stamp `site` onto every splice and taint part that has no site yet.
427pub fn stamp_part_sites(parts: &mut [StringPart], site: Option<&Rc<SiteFacts>>) {
428    if site.is_none() {
429        return;
430    }
431    for part in parts {
432        match part {
433            StringPart::Text(_) => {}
434            StringPart::Splice(splice) => {
435                if splice.meta.site.is_none() {
436                    splice.meta.site = site.cloned();
437                }
438            }
439            StringPart::Taint(taint) => {
440                if taint.site.is_none() {
441                    taint.site = site.cloned();
442                }
443            }
444        }
445    }
446}