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