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}