Skip to main content

shape_vm/compiler/
string_interpolation.rs

1//! Compile-time string interpolation compilation.
2//!
3//! Interpolation syntax parsing itself lives in `shape-ast` so compiler,
4//! type inference, and LSP all use the same parser.
5
6use crate::bytecode::{BuiltinFunction, Constant, Instruction, OpCode, Operand};
7use crate::compiler::BytecodeCompiler;
8use shape_ast::ast::InterpolationMode;
9use shape_ast::content_style::{ColorSpec, ContentFormatSpec, NamedContentColor};
10use shape_ast::error::{Result, ShapeError};
11use shape_ast::interpolation::{
12    FormatAlignment, FormatColor, InterpolationFormatSpec, InterpolationPart,
13    parse_interpolation_with_mode,
14};
15pub use shape_ast::interpolation::{has_interpolation, has_interpolation_with_mode};
16
17const FORMAT_SPEC_FIXED: i64 = 1;
18const FORMAT_SPEC_TABLE: i64 = 2;
19
20// R8 W4 W18.4: encoding constants mirroring `executor/vm_impl/builtins.rs`
21// `decode_fstring_*` helpers. Keep these in lockstep.
22const FSTRING_COLOR_NONE: i64 = -1;
23const FSTRING_COLOR_NAMED: i64 = 0;
24const FSTRING_COLOR_RGB: i64 = 1;
25
26const FSTRING_FLAG_BOLD: i64 = 1;
27const FSTRING_FLAG_ITALIC: i64 = 2;
28const FSTRING_FLAG_UNDERLINE: i64 = 4;
29const FSTRING_FLAG_DIM: i64 = 8;
30
31fn encode_color_args(color: Option<&ColorSpec>) -> (i64, i64) {
32    match color {
33        None => (FSTRING_COLOR_NONE, 0),
34        Some(ColorSpec::Named(named)) => {
35            let id: i64 = match named {
36                NamedContentColor::Red => 0,
37                NamedContentColor::Green => 1,
38                NamedContentColor::Blue => 2,
39                NamedContentColor::Yellow => 3,
40                NamedContentColor::Magenta => 4,
41                NamedContentColor::Cyan => 5,
42                NamedContentColor::White => 6,
43                NamedContentColor::Default => 7,
44            };
45            (FSTRING_COLOR_NAMED, id)
46        }
47        Some(ColorSpec::Rgb(r, g, b)) => {
48            let payload = ((*r as i64) << 16) | ((*g as i64) << 8) | (*b as i64);
49            (FSTRING_COLOR_RGB, payload)
50        }
51    }
52}
53
54fn encode_flag_bits(spec: &ContentFormatSpec) -> i64 {
55    let mut bits: i64 = 0;
56    if spec.bold {
57        bits |= FSTRING_FLAG_BOLD;
58    }
59    if spec.italic {
60        bits |= FSTRING_FLAG_ITALIC;
61    }
62    if spec.underline {
63        bits |= FSTRING_FLAG_UNDERLINE;
64    }
65    if spec.dim {
66        bits |= FSTRING_FLAG_DIM;
67    }
68    bits
69}
70
71/// R8 W4 W18.4: presence-test for any `ContentStyle` arm in a parsed
72/// f-string. Drives the D1 syntax-determined `string` → `content` flip:
73/// if any interpolation part carries a content-styling spec, the whole
74/// f-string lowers through the `ContentNode::Fragment` path and the
75/// inference engine reports `content` as the return type.
76pub fn has_content_style_spec(parts: &[InterpolationPart]) -> bool {
77    parts.iter().any(|p| {
78        matches!(
79            p,
80            InterpolationPart::Expression {
81                format_spec: Some(InterpolationFormatSpec::ContentStyle(_)),
82                ..
83            }
84        )
85    })
86}
87
88impl BytecodeCompiler {
89    fn emit_interpolation_format_call(
90        &mut self,
91        format_spec: Option<&InterpolationFormatSpec>,
92    ) -> Result<()> {
93        match format_spec {
94            None => {
95                // Args: [value]
96                let count = self.program.add_constant(Constant::Int(1));
97                self.emit(Instruction::new(
98                    OpCode::PushConst,
99                    Some(Operand::Const(count)),
100                ));
101                self.emit(Instruction::new(
102                    OpCode::BuiltinCall,
103                    Some(Operand::Builtin(BuiltinFunction::FormatValueWithMeta)),
104                ));
105            }
106            Some(InterpolationFormatSpec::Fixed { precision }) => {
107                // Args: [value, spec_tag, precision]
108                let tag = self.program.add_constant(Constant::Int(FORMAT_SPEC_FIXED));
109                self.emit(Instruction::new(
110                    OpCode::PushConst,
111                    Some(Operand::Const(tag)),
112                ));
113                let precision = self.program.add_constant(Constant::Int(*precision as i64));
114                self.emit(Instruction::new(
115                    OpCode::PushConst,
116                    Some(Operand::Const(precision)),
117                ));
118                let count = self.program.add_constant(Constant::Int(3));
119                self.emit(Instruction::new(
120                    OpCode::PushConst,
121                    Some(Operand::Const(count)),
122                ));
123                self.emit(Instruction::new(
124                    OpCode::BuiltinCall,
125                    Some(Operand::Builtin(BuiltinFunction::FormatValueWithSpec)),
126                ));
127            }
128            Some(InterpolationFormatSpec::ContentStyle(_)) => {
129                // Unreachable on the string-concat lowering path:
130                // `compile_interpolated_string_expression` dispatches to
131                // the content-fragment path BEFORE this is called when any
132                // part has a `ContentStyle` spec. Reaching here means the
133                // dispatch decision is out of sync with this match — a
134                // compiler bug, not user error.
135                return Err(ShapeError::RuntimeError {
136                    message: "internal: ContentStyle reached \
137                              string-concat emitter — \
138                              compile_interpolated_string_expression \
139                              dispatch is out of sync"
140                        .to_string(),
141                    location: None,
142                });
143            }
144            Some(InterpolationFormatSpec::Table(spec)) => {
145                // Args: [value, spec_tag, max_rows, align, precision, color, border]
146                let tag = self.program.add_constant(Constant::Int(FORMAT_SPEC_TABLE));
147                self.emit(Instruction::new(
148                    OpCode::PushConst,
149                    Some(Operand::Const(tag)),
150                ));
151
152                let max_rows = self
153                    .program
154                    .add_constant(Constant::Int(spec.max_rows.map(|v| v as i64).unwrap_or(-1)));
155                self.emit(Instruction::new(
156                    OpCode::PushConst,
157                    Some(Operand::Const(max_rows)),
158                ));
159
160                let align = self.program.add_constant(Constant::Int(
161                    spec.align
162                        .map(|v| match v {
163                            FormatAlignment::Left => 0,
164                            FormatAlignment::Center => 1,
165                            FormatAlignment::Right => 2,
166                        })
167                        .unwrap_or(-1),
168                ));
169                self.emit(Instruction::new(
170                    OpCode::PushConst,
171                    Some(Operand::Const(align)),
172                ));
173
174                let precision = self.program.add_constant(Constant::Int(
175                    spec.precision.map(|v| v as i64).unwrap_or(-1),
176                ));
177                self.emit(Instruction::new(
178                    OpCode::PushConst,
179                    Some(Operand::Const(precision)),
180                ));
181
182                let color = self.program.add_constant(Constant::Int(
183                    spec.color
184                        .map(|v| match v {
185                            FormatColor::Default => 0,
186                            FormatColor::Red => 1,
187                            FormatColor::Green => 2,
188                            FormatColor::Yellow => 3,
189                            FormatColor::Blue => 4,
190                            FormatColor::Magenta => 5,
191                            FormatColor::Cyan => 6,
192                            FormatColor::White => 7,
193                        })
194                        .unwrap_or(-1),
195                ));
196                self.emit(Instruction::new(
197                    OpCode::PushConst,
198                    Some(Operand::Const(color)),
199                ));
200
201                let border = self.program.add_constant(Constant::Bool(spec.border));
202                self.emit(Instruction::new(
203                    OpCode::PushConst,
204                    Some(Operand::Const(border)),
205                ));
206
207                let count = self.program.add_constant(Constant::Int(7));
208                self.emit(Instruction::new(
209                    OpCode::PushConst,
210                    Some(Operand::Const(count)),
211                ));
212                self.emit(Instruction::new(
213                    OpCode::BuiltinCall,
214                    Some(Operand::Builtin(BuiltinFunction::FormatValueWithSpec)),
215                ));
216            }
217        }
218
219        Ok(())
220    }
221
222    /// Compile an interpolated string. Per R8 W4 W18.4 (supervisor
223    /// 2026-05-24 D1 + (a-modified) REVIVE-WITH-SHARED-MODULE), the
224    /// lowering shape is syntax-determined:
225    ///
226    /// - If NO part carries a `ContentStyle` spec: traditional
227    ///   string-concat path (preserves the `string` return type used by
228    ///   500+ existing call sites).
229    /// - If ANY part carries a `ContentStyle` spec: lower to a
230    ///   `ContentNode::Fragment` of styled/plain text nodes; return type
231    ///   is `content` (`Ptr(HeapKind::Content)`).
232    pub(in crate::compiler) fn compile_interpolated_string_expression(
233        &mut self,
234        s: &str,
235        mode: InterpolationMode,
236    ) -> Result<()> {
237        let parts = parse_interpolation_with_mode(s, mode)?;
238
239        if has_content_style_spec(&parts) {
240            self.compile_interpolated_string_as_content(&parts)
241        } else {
242            self.compile_interpolated_string_as_string(parts)
243        }
244    }
245
246    /// Original f-string lowering path: every part lowers to a string,
247    /// concatenated via `StringConcat`. Preserved verbatim from pre-W18.4
248    /// behaviour — every existing call site without styling syntax stays
249    /// on this path.
250    fn compile_interpolated_string_as_string(
251        &mut self,
252        parts: Vec<InterpolationPart>,
253    ) -> Result<()> {
254        if parts.is_empty() {
255            // Empty string
256            let const_idx = self.program.add_constant(Constant::String(String::new()));
257            self.emit(Instruction::new(
258                OpCode::PushConst,
259                Some(Operand::Const(const_idx)),
260            ));
261            return Ok(());
262        }
263
264        let mut first = true;
265
266        for part in parts {
267            match part {
268                InterpolationPart::Literal(text) => {
269                    let const_idx = self.program.add_constant(Constant::String(text));
270                    self.emit(Instruction::new(
271                        OpCode::PushConst,
272                        Some(Operand::Const(const_idx)),
273                    ));
274                }
275                InterpolationPart::Expression { expr, format_spec } => {
276                    // Parse the expression string
277                    let expr = shape_ast::parser::parse_expression_str(&expr).map_err(|e| {
278                        ShapeError::RuntimeError {
279                            message: format!(
280                                "Failed to parse expression '{}' in interpolation: {}",
281                                expr, e
282                            ),
283                            location: None,
284                        }
285                    })?;
286
287                    // Compile the expression
288                    self.compile_expr(&expr)?;
289
290                    // Format value using typed interpolation spec.
291                    self.emit_interpolation_format_call(format_spec.as_ref())?;
292                }
293            }
294
295            // Concatenate with previous result (except for first part).
296            // Every interpolation part is statically a String — literal parts
297            // come from `Constant::String` and expression parts go through
298            // `emit_interpolation_format_call` which always produces a string.
299            if !first {
300                self.emit(Instruction::simple(OpCode::StringConcat));
301            }
302            first = false;
303        }
304
305        Ok(())
306    }
307
308    /// R8 W4 W18.4 content-fragment lowering:
309    /// 1. For each plain literal part: push the string, call
310    ///    `FStringContentText` → `ContentNode::Text` plain span on stack.
311    /// 2. For each expression with NO styling: compile expr →
312    ///    `FormatValueWithMeta` to string → `FStringContentText`.
313    /// 3. For each expression WITH `ContentStyle` spec: compile expr →
314    ///    `FormatValueWithMeta` to string → push encoded style args →
315    ///    `FStringContentStyledText` → styled `ContentNode::Text` on stack.
316    /// 4. After all parts emitted: push count → `FStringContentFragment`
317    ///    → `ContentNode::Fragment` on stack.
318    fn compile_interpolated_string_as_content(
319        &mut self,
320        parts: &[InterpolationPart],
321    ) -> Result<()> {
322        if parts.is_empty() {
323            // Empty f-string with no parts shouldn't reach here (we only
324            // dispatch when content-style is present), but be defensive:
325            // emit a plain empty content text.
326            self.emit_empty_content_text()?;
327            return Ok(());
328        }
329
330        let part_count = parts.len();
331
332        for part in parts {
333            match part {
334                InterpolationPart::Literal(text) => {
335                    // Push literal string, then FStringContentText.
336                    let const_idx =
337                        self.program.add_constant(Constant::String(text.clone()));
338                    self.emit(Instruction::new(
339                        OpCode::PushConst,
340                        Some(Operand::Const(const_idx)),
341                    ));
342                    self.emit_fstring_content_text_call()?;
343                }
344                InterpolationPart::Expression { expr, format_spec } => {
345                    let parsed_expr =
346                        shape_ast::parser::parse_expression_str(expr).map_err(|e| {
347                            ShapeError::RuntimeError {
348                                message: format!(
349                                    "Failed to parse expression '{}' in \
350                                     interpolation: {}",
351                                    expr, e
352                                ),
353                                location: None,
354                            }
355                        })?;
356                    self.compile_expr(&parsed_expr)?;
357
358                    match format_spec {
359                        Some(InterpolationFormatSpec::ContentStyle(spec)) => {
360                            // Convert expression value to string first.
361                            self.emit_format_value_with_meta()?;
362                            // Then emit FStringContentStyledText with
363                            // encoded style payload.
364                            self.emit_fstring_content_styled_text(spec)?;
365                        }
366                        _ => {
367                            // Plain or fixed/table-spec'd expression: route
368                            // through the existing format path to a string,
369                            // then wrap as plain content.
370                            self.emit_interpolation_format_call(
371                                format_spec.as_ref(),
372                            )?;
373                            self.emit_fstring_content_text_call()?;
374                        }
375                    }
376                }
377            }
378        }
379
380        // Combine all part-results into a Fragment.
381        let count_idx = self
382            .program
383            .add_constant(Constant::Int(part_count as i64));
384        self.emit(Instruction::new(
385            OpCode::PushConst,
386            Some(Operand::Const(count_idx)),
387        ));
388        self.emit(Instruction::new(
389            OpCode::BuiltinCall,
390            Some(Operand::Builtin(BuiltinFunction::FStringContentFragment)),
391        ));
392
393        Ok(())
394    }
395
396    fn emit_format_value_with_meta(&mut self) -> Result<()> {
397        let count = self.program.add_constant(Constant::Int(1));
398        self.emit(Instruction::new(
399            OpCode::PushConst,
400            Some(Operand::Const(count)),
401        ));
402        self.emit(Instruction::new(
403            OpCode::BuiltinCall,
404            Some(Operand::Builtin(BuiltinFunction::FormatValueWithMeta)),
405        ));
406        Ok(())
407    }
408
409    fn emit_fstring_content_text_call(&mut self) -> Result<()> {
410        let count = self.program.add_constant(Constant::Int(1));
411        self.emit(Instruction::new(
412            OpCode::PushConst,
413            Some(Operand::Const(count)),
414        ));
415        self.emit(Instruction::new(
416            OpCode::BuiltinCall,
417            Some(Operand::Builtin(BuiltinFunction::FStringContentText)),
418        ));
419        Ok(())
420    }
421
422    fn emit_fstring_content_styled_text(
423        &mut self,
424        spec: &ContentFormatSpec,
425    ) -> Result<()> {
426        // Stack on entry: [value_str]. Push 5 i64 style args, then 6 as
427        // arg-count, then BuiltinCall.
428        let (fg_kind, fg_payload) = encode_color_args(spec.fg.as_ref());
429        let (bg_kind, bg_payload) = encode_color_args(spec.bg.as_ref());
430        let flags = encode_flag_bits(spec);
431
432        for v in [fg_kind, fg_payload, bg_kind, bg_payload, flags] {
433            let idx = self.program.add_constant(Constant::Int(v));
434            self.emit(Instruction::new(
435                OpCode::PushConst,
436                Some(Operand::Const(idx)),
437            ));
438        }
439        let count_idx = self.program.add_constant(Constant::Int(6));
440        self.emit(Instruction::new(
441            OpCode::PushConst,
442            Some(Operand::Const(count_idx)),
443        ));
444        self.emit(Instruction::new(
445            OpCode::BuiltinCall,
446            Some(Operand::Builtin(BuiltinFunction::FStringContentStyledText)),
447        ));
448        Ok(())
449    }
450
451    fn emit_empty_content_text(&mut self) -> Result<()> {
452        let const_idx = self.program.add_constant(Constant::String(String::new()));
453        self.emit(Instruction::new(
454            OpCode::PushConst,
455            Some(Operand::Const(const_idx)),
456        ));
457        self.emit_fstring_content_text_call()
458    }
459
460}
461
462#[cfg(test)]
463mod tests {
464    use super::*;
465    use shape_ast::interpolation::parse_interpolation_with_mode;
466
467    fn parse_braces(s: &str) -> shape_ast::error::Result<Vec<InterpolationPart>> {
468        parse_interpolation_with_mode(s, InterpolationMode::Braces)
469    }
470
471    #[test]
472    fn test_no_interpolation() {
473        let parts = parse_braces("Hello World").unwrap();
474        assert_eq!(parts.len(), 1);
475        assert!(matches!(&parts[0], InterpolationPart::Literal(s) if s == "Hello World"));
476    }
477
478    #[test]
479    fn test_simple_interpolation() {
480        let parts = parse_braces("value: {x}").unwrap();
481        assert_eq!(parts.len(), 2);
482        assert!(matches!(&parts[0], InterpolationPart::Literal(s) if s == "value: "));
483        assert!(matches!(
484            &parts[1],
485            InterpolationPart::Expression {
486                expr,
487                format_spec: None
488            } if expr == "x"
489        ));
490    }
491
492    #[test]
493    fn test_expression_interpolation() {
494        let parts = parse_braces("sum: {x + y}").unwrap();
495        assert_eq!(parts.len(), 2);
496        assert!(matches!(&parts[0], InterpolationPart::Literal(s) if s == "sum: "));
497        assert!(matches!(
498            &parts[1],
499            InterpolationPart::Expression {
500                expr,
501                format_spec: None
502            } if expr == "x + y"
503        ));
504    }
505
506    #[test]
507    fn test_multiple_interpolations() {
508        let parts = parse_braces("a={a}, b={b}").unwrap();
509        assert_eq!(parts.len(), 4);
510        assert!(matches!(&parts[0], InterpolationPart::Literal(s) if s == "a="));
511        assert!(matches!(
512            &parts[1],
513            InterpolationPart::Expression {
514                expr,
515                format_spec: None
516            } if expr == "a"
517        ));
518        assert!(matches!(&parts[2], InterpolationPart::Literal(s) if s == ", b="));
519        assert!(matches!(
520            &parts[3],
521            InterpolationPart::Expression {
522                expr,
523                format_spec: None
524            } if expr == "b"
525        ));
526    }
527
528    #[test]
529    fn test_escaped_braces() {
530        let parts = parse_braces("Use {{x}} for literal").unwrap();
531        assert_eq!(parts.len(), 1);
532        assert!(matches!(&parts[0], InterpolationPart::Literal(s) if s == "Use {x} for literal"));
533    }
534
535    #[test]
536    fn test_as_type_in_interpolation() {
537        let parts = parse_braces("{x as Percent}").unwrap();
538        assert_eq!(parts.len(), 1);
539        assert!(matches!(
540            &parts[0],
541            InterpolationPart::Expression {
542                expr,
543                format_spec: None
544            } if expr == "x as Percent"
545        ));
546    }
547
548    #[test]
549    fn test_nested_braces_in_object() {
550        let parts = parse_braces("obj: {x.method({a: 1})}").unwrap();
551        assert_eq!(parts.len(), 2);
552        assert!(matches!(
553            &parts[1],
554            InterpolationPart::Expression {
555                expr,
556                format_spec: None
557            } if expr == "x.method({a: 1})"
558        ));
559    }
560
561    #[test]
562    fn test_interpolation_with_format_spec() {
563        let parts = parse_braces("px={price:fixed(2)}").unwrap();
564        assert_eq!(parts.len(), 2);
565        assert!(matches!(&parts[0], InterpolationPart::Literal(s) if s == "px="));
566        assert!(matches!(
567            &parts[1],
568            InterpolationPart::Expression {
569                expr,
570                format_spec: Some(spec)
571            } if expr == "price"
572                && *spec == InterpolationFormatSpec::Fixed { precision: 2 }
573        ));
574    }
575
576    #[test]
577    fn test_interpolation_does_not_split_double_colon() {
578        let parts = parse_braces("{Type::Variant}").unwrap();
579        assert_eq!(parts.len(), 1);
580        assert!(matches!(
581            &parts[0],
582            InterpolationPart::Expression {
583                expr,
584                format_spec: None
585            } if expr == "Type::Variant"
586        ));
587    }
588
589    #[test]
590    fn test_missing_format_spec_error() {
591        let result = parse_braces("value: {x:}");
592        assert!(result.is_err());
593    }
594
595    #[test]
596    fn test_unmatched_close_brace_error() {
597        let result = parse_braces("value: }");
598        assert!(result.is_err());
599    }
600
601    #[test]
602    fn test_has_interpolation() {
603        assert!(has_interpolation_with_mode(
604            "value: {x}",
605            InterpolationMode::Braces
606        ));
607        assert!(has_interpolation_with_mode(
608            "{x + y}",
609            InterpolationMode::Braces
610        ));
611        assert!(!has_interpolation_with_mode(
612            "Hello World",
613            InterpolationMode::Braces
614        ));
615        assert!(!has_interpolation_with_mode(
616            "Use {{x}} for literal",
617            InterpolationMode::Braces
618        )); // Escaped, no real interpolation
619    }
620
621    #[test]
622    fn test_empty_interpolation_error() {
623        let result = parse_braces("value: {}");
624        assert!(result.is_err());
625    }
626
627    #[test]
628    fn test_dollar_mode_interpolation() {
629        let parts =
630            parse_interpolation_with_mode("{\"name\": ${user.name}}", InterpolationMode::Dollar)
631                .unwrap();
632        assert_eq!(parts.len(), 3);
633        assert!(matches!(
634            &parts[0],
635            InterpolationPart::Literal(s) if s == "{\"name\": "
636        ));
637        assert!(matches!(
638            &parts[1],
639            InterpolationPart::Expression {
640                expr,
641                format_spec: None
642            } if expr == "user.name"
643        ));
644        assert!(matches!(&parts[2], InterpolationPart::Literal(s) if s == "}"));
645    }
646}