Skip to main content

compose_lens/interpolation/
mod.rs

1//! Explicit, non-destructive Compose variable interpolation.
2
3use crate::diagnostic::{Diagnostic, DiagnosticCode, DiagnosticLabel, Severity};
4use crate::source::{SourceId, SourceSpan};
5use crate::syntax::SyntaxDocument;
6use std::collections::BTreeMap;
7
8/// An unset direct substitution was replaced according to the configured policy.
9pub const UNSET_VARIABLE: DiagnosticCode = DiagnosticCode::new("compose.interpolation.unset-variable");
10
11/// A required interpolation variable was unset or empty.
12pub const REQUIRED_VARIABLE: DiagnosticCode = DiagnosticCode::new("compose.interpolation.required-variable");
13
14/// A braced interpolation expression is malformed or unsupported.
15pub const INVALID_EXPRESSION: DiagnosticCode = DiagnosticCode::new("compose.interpolation.invalid-expression");
16
17/// Nested interpolation exceeded the configured safety limit.
18pub const NESTING_LIMIT: DiagnosticCode = DiagnosticCode::new("compose.interpolation.nesting-limit");
19
20/// One value supplied by an explicit interpolation environment.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct EnvironmentValue {
23    value: String,
24    sensitive: bool,
25}
26
27impl EnvironmentValue {
28    /// Creates a non-sensitive value.
29    #[must_use]
30    pub fn plain(value: impl Into<String>) -> Self {
31        Self {
32            value: value.into(),
33            sensitive: false,
34        }
35    }
36
37    /// Creates a value whose use makes the interpolation result sensitive.
38    #[must_use]
39    pub fn sensitive(value: impl Into<String>) -> Self {
40        Self {
41            value: value.into(),
42            sensitive: true,
43        }
44    }
45
46    /// Returns the supplied value.
47    #[must_use]
48    pub fn value(&self) -> &str {
49        &self.value
50    }
51
52    /// Reports whether callers should redact the value when displaying the result.
53    #[must_use]
54    pub const fn is_sensitive(&self) -> bool {
55        self.sensitive
56    }
57}
58
59/// Supplies variables to interpolation without granting implicit process-environment access.
60pub trait EnvironmentProvider {
61    /// Returns a variable value, or `None` when the variable is unset.
62    fn get(&self, name: &str) -> Option<EnvironmentValue>;
63}
64
65/// An explicit environment that never contains a variable.
66#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
67pub struct EmptyEnvironment;
68
69impl EnvironmentProvider for EmptyEnvironment {
70    fn get(&self, _name: &str) -> Option<EnvironmentValue> {
71        None
72    }
73}
74
75/// A deterministic caller-owned interpolation environment.
76#[derive(Debug, Clone, Default, PartialEq, Eq)]
77pub struct MapEnvironment {
78    values: BTreeMap<String, EnvironmentValue>,
79}
80
81impl MapEnvironment {
82    /// Creates an empty environment map.
83    #[must_use]
84    pub const fn new() -> Self {
85        Self {
86            values: BTreeMap::new(),
87        }
88    }
89
90    /// Inserts a non-sensitive value and returns the replaced value, if any.
91    pub fn insert(&mut self, name: impl Into<String>, value: impl Into<String>) -> Option<EnvironmentValue> {
92        self.values.insert(name.into(), EnvironmentValue::plain(value))
93    }
94
95    /// Inserts a sensitive value and returns the replaced value, if any.
96    pub fn insert_sensitive(&mut self, name: impl Into<String>, value: impl Into<String>) -> Option<EnvironmentValue> {
97        self.values.insert(name.into(), EnvironmentValue::sensitive(value))
98    }
99
100    /// Inserts an already classified value and returns the replaced value, if any.
101    pub fn insert_value(&mut self, name: impl Into<String>, value: EnvironmentValue) -> Option<EnvironmentValue> {
102        self.values.insert(name.into(), value)
103    }
104
105    /// Returns the number of configured variables.
106    #[must_use]
107    pub fn len(&self) -> usize {
108        self.values.len()
109    }
110
111    /// Reports whether no variables are configured.
112    #[must_use]
113    pub fn is_empty(&self) -> bool {
114        self.values.is_empty()
115    }
116}
117
118impl EnvironmentProvider for MapEnvironment {
119    fn get(&self, name: &str) -> Option<EnvironmentValue> {
120        self.values.get(name).cloned()
121    }
122}
123
124/// Behavior for an unset direct `$VAR` or `${VAR}` substitution.
125#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
126pub enum MissingVariablePolicy {
127    /// Match Compose's documented behavior: warn and substitute an empty string.
128    EmptyWithWarning,
129    /// Warn and retain the original expression in the recovered value.
130    PreserveWithWarning,
131    /// Emit an error and retain the original expression in the recovered value.
132    Error,
133}
134
135/// Controls one explicit interpolation operation.
136#[derive(Debug, Clone, Copy, PartialEq, Eq)]
137pub struct InterpolationOptions {
138    missing_variable: MissingVariablePolicy,
139    max_nesting: usize,
140}
141
142impl InterpolationOptions {
143    /// Creates options using the requested missing-variable policy.
144    #[must_use]
145    pub const fn new(missing_variable: MissingVariablePolicy) -> Self {
146        Self {
147            missing_variable,
148            max_nesting: 32,
149        }
150    }
151
152    /// Sets the maximum nested-expression depth; zero is promoted to one.
153    #[must_use]
154    pub fn with_max_nesting(mut self, max_nesting: usize) -> Self {
155        self.max_nesting = max_nesting.max(1);
156        self
157    }
158
159    /// Returns the direct missing-variable policy.
160    #[must_use]
161    pub const fn missing_variable(self) -> MissingVariablePolicy {
162        self.missing_variable
163    }
164
165    /// Returns the maximum nested-expression depth.
166    #[must_use]
167    pub const fn max_nesting(self) -> usize {
168        self.max_nesting
169    }
170}
171
172impl Default for InterpolationOptions {
173    fn default() -> Self {
174        Self::new(MissingVariablePolicy::EmptyWithWarning)
175    }
176}
177
178/// A source-aware scalar supplied to the interpolation kernel.
179#[derive(Debug, Clone, Copy, PartialEq, Eq)]
180pub struct InterpolationInput<'a> {
181    value: &'a str,
182    span: SourceSpan,
183    sensitive: bool,
184}
185
186impl<'a> InterpolationInput<'a> {
187    /// Creates a non-sensitive input.
188    #[must_use]
189    pub const fn new(value: &'a str, span: SourceSpan) -> Self {
190        Self {
191            value,
192            span,
193            sensitive: false,
194        }
195    }
196
197    /// Marks the authored scalar as sensitive.
198    #[must_use]
199    pub const fn sensitive(mut self) -> Self {
200        self.sensitive = true;
201        self
202    }
203
204    /// Returns the uninterpolated semantic scalar.
205    #[must_use]
206    pub const fn value(self) -> &'a str {
207        self.value
208    }
209
210    /// Returns the scalar's source span.
211    #[must_use]
212    pub const fn span(self) -> SourceSpan {
213        self.span
214    }
215
216    /// Reports whether the authored scalar is sensitive.
217    #[must_use]
218    pub const fn is_sensitive(self) -> bool {
219        self.sensitive
220    }
221}
222
223/// The operator used by one interpolation expression.
224#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
225pub enum InterpolationOperator {
226    /// `$VAR` or `${VAR}`.
227    Direct,
228    /// `${VAR:-default}`.
229    DefaultIfUnsetOrEmpty,
230    /// `${VAR-default}`.
231    DefaultIfUnset,
232    /// `${VAR:?message}`.
233    RequiredIfUnsetOrEmpty,
234    /// `${VAR?message}`.
235    RequiredIfUnset,
236    /// `${VAR:+alternative}`.
237    AlternativeIfSetAndNonEmpty,
238    /// `${VAR+alternative}`.
239    AlternativeIfSet,
240}
241
242/// How one expression contributed to the resolved value.
243#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
244pub enum SubstitutionOutcome {
245    /// An environment value was inserted.
246    Environment,
247    /// A default operand was inserted.
248    Default,
249    /// An alternative operand was inserted.
250    Alternative,
251    /// The expression intentionally produced an empty string.
252    Empty,
253    /// A direct variable was unset.
254    Missing,
255    /// A required variable was unset or empty.
256    RequiredMissing,
257}
258
259/// Provenance for one evaluated variable expression.
260#[derive(Debug, Clone, PartialEq, Eq)]
261pub struct Substitution {
262    name: String,
263    operator: InterpolationOperator,
264    outcome: SubstitutionOutcome,
265    span: SourceSpan,
266    sensitive: bool,
267}
268
269impl Substitution {
270    /// Returns the referenced variable name.
271    #[must_use]
272    pub fn name(&self) -> &str {
273        &self.name
274    }
275
276    /// Returns the expression operator.
277    #[must_use]
278    pub const fn operator(&self) -> InterpolationOperator {
279        self.operator
280    }
281
282    /// Returns how the expression contributed to the result.
283    #[must_use]
284    pub const fn outcome(&self) -> SubstitutionOutcome {
285        self.outcome
286    }
287
288    /// Returns the containing scalar's source span.
289    #[must_use]
290    pub const fn span(&self) -> SourceSpan {
291        self.span
292    }
293
294    /// Reports whether this substitution inserted sensitive content.
295    #[must_use]
296    pub const fn is_sensitive(&self) -> bool {
297        self.sensitive
298    }
299}
300
301/// A recoverable, non-destructive interpolation result.
302#[derive(Debug, Clone, PartialEq, Eq)]
303pub struct InterpolationResult {
304    original: String,
305    resolved: String,
306    span: SourceSpan,
307    sensitive: bool,
308    substitutions: Vec<Substitution>,
309    diagnostics: Vec<Diagnostic>,
310}
311
312/// A non-destructive interpolation overlay for one syntax document.
313#[derive(Debug, Clone, PartialEq, Eq)]
314pub struct DocumentInterpolation {
315    source_id: SourceId,
316    values: Vec<InterpolationResult>,
317    diagnostics: Vec<Diagnostic>,
318}
319
320impl DocumentInterpolation {
321    /// Returns the interpolated source-document identifier.
322    #[must_use]
323    pub const fn source_id(&self) -> SourceId {
324        self.source_id
325    }
326
327    /// Returns eligible value-scalar results in source order.
328    #[must_use]
329    pub fn values(&self) -> &[InterpolationResult] {
330        &self.values
331    }
332
333    /// Finds the result for an exact value-scalar span.
334    #[must_use]
335    pub fn value(&self, span: SourceSpan) -> Option<&InterpolationResult> {
336        self.values.iter().find(|value| value.span == span)
337    }
338
339    /// Returns aggregated interpolation diagnostics in source order.
340    #[must_use]
341    pub fn diagnostics(&self) -> &[Diagnostic] {
342        &self.diagnostics
343    }
344
345    /// Reports whether no value produced an error diagnostic.
346    #[must_use]
347    pub fn is_valid(&self) -> bool {
348        !self
349            .diagnostics
350            .iter()
351            .any(|diagnostic| diagnostic.severity() == Severity::Error)
352    }
353}
354
355impl InterpolationResult {
356    /// Returns the uninterpolated semantic scalar.
357    #[must_use]
358    pub fn original(&self) -> &str {
359        &self.original
360    }
361
362    /// Returns the recovered resolved value.
363    ///
364    /// Required-variable and invalid-expression errors retain the offending expression so callers
365    /// can continue analysis without silently losing source intent.
366    #[must_use]
367    pub fn resolved(&self) -> &str {
368        &self.resolved
369    }
370
371    /// Returns the source span of the containing scalar.
372    #[must_use]
373    pub const fn span(&self) -> SourceSpan {
374        self.span
375    }
376
377    /// Reports whether the input or an inserted value is sensitive.
378    #[must_use]
379    pub const fn is_sensitive(&self) -> bool {
380        self.sensitive
381    }
382
383    /// Returns evaluated expressions in evaluation order.
384    #[must_use]
385    pub fn substitutions(&self) -> &[Substitution] {
386        &self.substitutions
387    }
388
389    /// Returns interpolation diagnostics.
390    #[must_use]
391    pub fn diagnostics(&self) -> &[Diagnostic] {
392        &self.diagnostics
393    }
394
395    /// Reports whether interpolation emitted no error diagnostics.
396    #[must_use]
397    pub fn is_valid(&self) -> bool {
398        !self
399            .diagnostics
400            .iter()
401            .any(|diagnostic| diagnostic.severity() == Severity::Error)
402    }
403}
404
405/// Interpolates one scalar using Compose's documented direct, default, required, and alternative
406/// operators.
407#[must_use]
408pub fn interpolate(input: InterpolationInput<'_>, environment: &dyn EnvironmentProvider) -> InterpolationResult {
409    interpolate_with_options(input, environment, InterpolationOptions::default())
410}
411
412/// Interpolates one scalar with explicit missing-variable and nesting policies.
413#[must_use]
414pub fn interpolate_with_options(
415    input: InterpolationInput<'_>,
416    environment: &dyn EnvironmentProvider,
417    options: InterpolationOptions,
418) -> InterpolationResult {
419    let mut context = Context {
420        environment,
421        options,
422        span: input.span,
423        diagnostics: Vec::new(),
424        substitutions: Vec::new(),
425    };
426    let fragment = context.interpolate_text(input.value, 0);
427    InterpolationResult {
428        original: input.value.to_owned(),
429        resolved: fragment.value,
430        span: input.span,
431        sensitive: input.sensitive || fragment.sensitive,
432        substitutions: context.substitutions,
433        diagnostics: context.diagnostics,
434    }
435}
436
437/// Interpolates eligible YAML value scalars in one syntax document without modifying its source.
438///
439/// Mapping keys, single-quoted scalars, literal block scalars, and folded block scalars are not
440/// eligible. The returned overlay contains only values that included a dollar sign.
441#[must_use]
442pub fn interpolate_document(document: &SyntaxDocument, environment: &dyn EnvironmentProvider) -> DocumentInterpolation {
443    interpolate_document_with_options(document, environment, InterpolationOptions::default())
444}
445
446/// Interpolates eligible YAML value scalars with explicit options.
447#[must_use]
448pub fn interpolate_document_with_options(
449    document: &SyntaxDocument,
450    environment: &dyn EnvironmentProvider,
451    options: InterpolationOptions,
452) -> DocumentInterpolation {
453    let values: Vec<_> = document
454        .interpolatable_value_scalars()
455        .into_iter()
456        .map(|value| interpolate_with_options(InterpolationInput::new(&value.value, value.span), environment, options))
457        .collect();
458    let diagnostics = values
459        .iter()
460        .flat_map(|value| value.diagnostics.iter().cloned())
461        .collect();
462    DocumentInterpolation {
463        source_id: document.source_id(),
464        values,
465        diagnostics,
466    }
467}
468
469#[derive(Debug, Clone, PartialEq, Eq)]
470struct Fragment {
471    value: String,
472    sensitive: bool,
473}
474
475impl Fragment {
476    fn plain(value: impl Into<String>) -> Self {
477        Self {
478            value: value.into(),
479            sensitive: false,
480        }
481    }
482}
483
484struct Context<'a> {
485    environment: &'a dyn EnvironmentProvider,
486    options: InterpolationOptions,
487    span: SourceSpan,
488    diagnostics: Vec<Diagnostic>,
489    substitutions: Vec<Substitution>,
490}
491
492impl Context<'_> {
493    fn interpolate_text(&mut self, text: &str, depth: usize) -> Fragment {
494        if depth > self.options.max_nesting {
495            self.diagnostics.push(
496                Diagnostic::new(
497                    NESTING_LIMIT,
498                    Severity::Error,
499                    "interpolation nesting exceeds the configured safety limit",
500                )
501                .with_label(DiagnosticLabel::primary(self.span, "nested expression limit reached")),
502            );
503            return Fragment::plain(text);
504        }
505
506        let mut resolved = String::with_capacity(text.len());
507        let mut sensitive = false;
508        let mut cursor = 0;
509        while let Some(relative) = text[cursor..].find('$') {
510            let dollar = cursor + relative;
511            resolved.push_str(&text[cursor..dollar]);
512            let after_dollar = dollar + 1;
513            if after_dollar == text.len() {
514                resolved.push('$');
515                cursor = after_dollar;
516                break;
517            }
518
519            let next = text.as_bytes()[after_dollar];
520            if next == b'$' {
521                resolved.push('$');
522                cursor = after_dollar + 1;
523                continue;
524            }
525            if next == b'{' {
526                let expression_start = after_dollar + 1;
527                let Some(close) = find_closing_brace(text, expression_start) else {
528                    self.invalid_expression();
529                    resolved.push_str(&text[dollar..]);
530                    cursor = text.len();
531                    break;
532                };
533                let expression = &text[expression_start..close];
534                let original = &text[dollar..=close];
535                let fragment = self.evaluate_braced(expression, original, depth);
536                resolved.push_str(&fragment.value);
537                sensitive |= fragment.sensitive;
538                cursor = close + 1;
539                continue;
540            }
541            if is_name_start(next) {
542                let mut end = after_dollar + 1;
543                while end < text.len() && is_name_continue(text.as_bytes()[end]) {
544                    end += 1;
545                }
546                let name = &text[after_dollar..end];
547                let original = &text[dollar..end];
548                let fragment = self.evaluate(name, InterpolationOperator::Direct, "", original, depth);
549                resolved.push_str(&fragment.value);
550                sensitive |= fragment.sensitive;
551                cursor = end;
552                continue;
553            }
554
555            resolved.push('$');
556            cursor = after_dollar;
557        }
558        resolved.push_str(&text[cursor..]);
559        Fragment {
560            value: resolved,
561            sensitive,
562        }
563    }
564
565    fn evaluate_braced(&mut self, expression: &str, original: &str, depth: usize) -> Fragment {
566        let Some((name, operator, operand)) = parse_braced_expression(expression) else {
567            self.invalid_expression();
568            return Fragment::plain(original);
569        };
570        self.evaluate(name, operator, operand, original, depth)
571    }
572
573    fn evaluate(
574        &mut self,
575        name: &str,
576        operator: InterpolationOperator,
577        operand: &str,
578        original: &str,
579        depth: usize,
580    ) -> Fragment {
581        let environment = self.environment.get(name);
582        let is_set = environment.is_some();
583        let is_non_empty = environment.as_ref().is_some_and(|value| !value.value.is_empty());
584
585        let (fragment, outcome) = match operator {
586            InterpolationOperator::Direct => environment.map_or_else(
587                || self.missing_direct(name, original),
588                |value| {
589                    let fragment = Fragment {
590                        value: value.value,
591                        sensitive: value.sensitive,
592                    };
593                    (fragment, SubstitutionOutcome::Environment)
594                },
595            ),
596            InterpolationOperator::DefaultIfUnsetOrEmpty if !is_non_empty => {
597                (self.interpolate_text(operand, depth + 1), SubstitutionOutcome::Default)
598            }
599            InterpolationOperator::DefaultIfUnset if !is_set => {
600                (self.interpolate_text(operand, depth + 1), SubstitutionOutcome::Default)
601            }
602            InterpolationOperator::RequiredIfUnsetOrEmpty if !is_non_empty => {
603                self.required_missing(name);
604                (Fragment::plain(original), SubstitutionOutcome::RequiredMissing)
605            }
606            InterpolationOperator::RequiredIfUnset if !is_set => {
607                self.required_missing(name);
608                (Fragment::plain(original), SubstitutionOutcome::RequiredMissing)
609            }
610            InterpolationOperator::AlternativeIfSetAndNonEmpty if is_non_empty => (
611                self.interpolate_text(operand, depth + 1),
612                SubstitutionOutcome::Alternative,
613            ),
614            InterpolationOperator::AlternativeIfSet if is_set => (
615                self.interpolate_text(operand, depth + 1),
616                SubstitutionOutcome::Alternative,
617            ),
618            InterpolationOperator::AlternativeIfSetAndNonEmpty | InterpolationOperator::AlternativeIfSet => {
619                (Fragment::plain(""), SubstitutionOutcome::Empty)
620            }
621            InterpolationOperator::DefaultIfUnsetOrEmpty
622            | InterpolationOperator::DefaultIfUnset
623            | InterpolationOperator::RequiredIfUnsetOrEmpty
624            | InterpolationOperator::RequiredIfUnset => {
625                let value = environment.unwrap_or_else(|| EnvironmentValue::plain(""));
626                (
627                    Fragment {
628                        value: value.value,
629                        sensitive: value.sensitive,
630                    },
631                    SubstitutionOutcome::Environment,
632                )
633            }
634        };
635
636        self.substitutions.push(Substitution {
637            name: name.to_owned(),
638            operator,
639            outcome,
640            span: self.span,
641            sensitive: fragment.sensitive,
642        });
643        fragment
644    }
645
646    fn missing_direct(&mut self, name: &str, original: &str) -> (Fragment, SubstitutionOutcome) {
647        let (severity, value) = match self.options.missing_variable {
648            MissingVariablePolicy::EmptyWithWarning => (Severity::Warning, ""),
649            MissingVariablePolicy::PreserveWithWarning => (Severity::Warning, original),
650            MissingVariablePolicy::Error => (Severity::Error, original),
651        };
652        self.diagnostics.push(
653            Diagnostic::new(
654                UNSET_VARIABLE,
655                severity,
656                format!("interpolation variable `{name}` is not set"),
657            )
658            .with_label(DiagnosticLabel::primary(self.span, "unresolved variable expression")),
659        );
660        (Fragment::plain(value), SubstitutionOutcome::Missing)
661    }
662
663    fn required_missing(&mut self, name: &str) {
664        self.diagnostics.push(
665            Diagnostic::new(
666                REQUIRED_VARIABLE,
667                Severity::Error,
668                format!("required interpolation variable `{name}` is unset or empty"),
669            )
670            .with_label(DiagnosticLabel::primary(self.span, "required variable is unavailable")),
671        );
672    }
673
674    fn invalid_expression(&mut self) {
675        self.diagnostics.push(
676            Diagnostic::new(
677                INVALID_EXPRESSION,
678                Severity::Error,
679                "interpolation expression is malformed or unsupported",
680            )
681            .with_label(DiagnosticLabel::primary(self.span, "invalid interpolation expression")),
682        );
683    }
684}
685
686fn parse_braced_expression(expression: &str) -> Option<(&str, InterpolationOperator, &str)> {
687    let bytes = expression.as_bytes();
688    let first = *bytes.first()?;
689    if !is_name_start(first) {
690        return None;
691    }
692    let mut name_end = 1;
693    while name_end < bytes.len() && is_name_continue(bytes[name_end]) {
694        name_end += 1;
695    }
696    let name = &expression[..name_end];
697    let remainder = &expression[name_end..];
698    if remainder.is_empty() {
699        return Some((name, InterpolationOperator::Direct, ""));
700    }
701
702    for (prefix, operator) in [
703        (":-", InterpolationOperator::DefaultIfUnsetOrEmpty),
704        (":?", InterpolationOperator::RequiredIfUnsetOrEmpty),
705        (":+", InterpolationOperator::AlternativeIfSetAndNonEmpty),
706        ("-", InterpolationOperator::DefaultIfUnset),
707        ("?", InterpolationOperator::RequiredIfUnset),
708        ("+", InterpolationOperator::AlternativeIfSet),
709    ] {
710        if let Some(operand) = remainder.strip_prefix(prefix) {
711            return Some((name, operator, operand));
712        }
713    }
714    None
715}
716
717fn find_closing_brace(text: &str, start: usize) -> Option<usize> {
718    let mut depth = 1usize;
719    let mut cursor = start;
720    while cursor < text.len() {
721        if text[cursor..].starts_with("${") {
722            depth += 1;
723            cursor += 2;
724            continue;
725        }
726        let character = text[cursor..].chars().next()?;
727        if character == '}' {
728            depth -= 1;
729            if depth == 0 {
730                return Some(cursor);
731            }
732        }
733        cursor += character.len_utf8();
734    }
735    None
736}
737
738const fn is_name_start(byte: u8) -> bool {
739    byte == b'_' || byte.is_ascii_alphabetic()
740}
741
742const fn is_name_continue(byte: u8) -> bool {
743    is_name_start(byte) || byte.is_ascii_digit()
744}