Skip to main content

compose_lens/interpolation/
mod.rs

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