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::error::{Result, ShapeError};
10use shape_ast::interpolation::{
11    ChartTypeSpec, ColorSpec, ContentFormatSpec, FormatAlignment, FormatColor,
12    InterpolationFormatSpec, InterpolationPart, NamedContentColor,
13    parse_content_interpolation_with_mode, 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
20impl BytecodeCompiler {
21    fn emit_interpolation_format_call(
22        &mut self,
23        format_spec: Option<&InterpolationFormatSpec>,
24    ) -> Result<()> {
25        match format_spec {
26            None => {
27                // Args: [value]
28                let count = self.program.add_constant(Constant::Number(1.0));
29                self.emit(Instruction::new(
30                    OpCode::PushConst,
31                    Some(Operand::Const(count)),
32                ));
33                self.emit(Instruction::new(
34                    OpCode::BuiltinCall,
35                    Some(Operand::Builtin(BuiltinFunction::FormatValueWithMeta)),
36                ));
37            }
38            Some(InterpolationFormatSpec::Fixed { precision }) => {
39                // Args: [value, spec_tag, precision]
40                let tag = self.program.add_constant(Constant::Int(FORMAT_SPEC_FIXED));
41                self.emit(Instruction::new(
42                    OpCode::PushConst,
43                    Some(Operand::Const(tag)),
44                ));
45                let precision = self.program.add_constant(Constant::Int(*precision as i64));
46                self.emit(Instruction::new(
47                    OpCode::PushConst,
48                    Some(Operand::Const(precision)),
49                ));
50                let count = self.program.add_constant(Constant::Number(3.0));
51                self.emit(Instruction::new(
52                    OpCode::PushConst,
53                    Some(Operand::Const(count)),
54                ));
55                self.emit(Instruction::new(
56                    OpCode::BuiltinCall,
57                    Some(Operand::Builtin(BuiltinFunction::FormatValueWithSpec)),
58                ));
59            }
60            Some(InterpolationFormatSpec::Table(spec)) => {
61                // Args: [value, spec_tag, max_rows, align, precision, color, border]
62                let tag = self.program.add_constant(Constant::Int(FORMAT_SPEC_TABLE));
63                self.emit(Instruction::new(
64                    OpCode::PushConst,
65                    Some(Operand::Const(tag)),
66                ));
67
68                let max_rows = self
69                    .program
70                    .add_constant(Constant::Int(spec.max_rows.map(|v| v as i64).unwrap_or(-1)));
71                self.emit(Instruction::new(
72                    OpCode::PushConst,
73                    Some(Operand::Const(max_rows)),
74                ));
75
76                let align = self.program.add_constant(Constant::Int(
77                    spec.align
78                        .map(|v| match v {
79                            FormatAlignment::Left => 0,
80                            FormatAlignment::Center => 1,
81                            FormatAlignment::Right => 2,
82                        })
83                        .unwrap_or(-1),
84                ));
85                self.emit(Instruction::new(
86                    OpCode::PushConst,
87                    Some(Operand::Const(align)),
88                ));
89
90                let precision = self.program.add_constant(Constant::Int(
91                    spec.precision.map(|v| v as i64).unwrap_or(-1),
92                ));
93                self.emit(Instruction::new(
94                    OpCode::PushConst,
95                    Some(Operand::Const(precision)),
96                ));
97
98                let color = self.program.add_constant(Constant::Int(
99                    spec.color
100                        .map(|v| match v {
101                            FormatColor::Default => 0,
102                            FormatColor::Red => 1,
103                            FormatColor::Green => 2,
104                            FormatColor::Yellow => 3,
105                            FormatColor::Blue => 4,
106                            FormatColor::Magenta => 5,
107                            FormatColor::Cyan => 6,
108                            FormatColor::White => 7,
109                        })
110                        .unwrap_or(-1),
111                ));
112                self.emit(Instruction::new(
113                    OpCode::PushConst,
114                    Some(Operand::Const(color)),
115                ));
116
117                let border = self.program.add_constant(Constant::Bool(spec.border));
118                self.emit(Instruction::new(
119                    OpCode::PushConst,
120                    Some(Operand::Const(border)),
121                ));
122
123                let count = self.program.add_constant(Constant::Number(7.0));
124                self.emit(Instruction::new(
125                    OpCode::PushConst,
126                    Some(Operand::Const(count)),
127                ));
128                self.emit(Instruction::new(
129                    OpCode::BuiltinCall,
130                    Some(Operand::Builtin(BuiltinFunction::FormatValueWithSpec)),
131                ));
132            }
133            Some(InterpolationFormatSpec::ContentStyle(_)) => {
134                // Content style specs are handled at the content rendering level,
135                // not during string interpolation compilation.
136                // For now, treat as plain format (no spec).
137                let count = self.program.add_constant(Constant::Number(1.0));
138                self.emit(Instruction::new(
139                    OpCode::PushConst,
140                    Some(Operand::Const(count)),
141                ));
142                self.emit(Instruction::new(
143                    OpCode::BuiltinCall,
144                    Some(Operand::Builtin(BuiltinFunction::FormatValueWithMeta)),
145                ));
146            }
147        }
148
149        Ok(())
150    }
151
152    /// Compile an interpolated string, producing a single string value on the stack.
153    ///
154    /// For `text {expr} more`:
155    /// 1. Push literal `text `
156    /// 2. Compile expression, call `FormatValueWithMeta`
157    /// 3. Concatenate with `Add`
158    /// 4. Continue for remaining parts
159    pub(in crate::compiler) fn compile_interpolated_string_expression(
160        &mut self,
161        s: &str,
162        mode: InterpolationMode,
163    ) -> Result<()> {
164        let parts = parse_interpolation_with_mode(s, mode)?;
165
166        if parts.is_empty() {
167            // Empty string
168            let const_idx = self.program.add_constant(Constant::String(String::new()));
169            self.emit(Instruction::new(
170                OpCode::PushConst,
171                Some(Operand::Const(const_idx)),
172            ));
173            return Ok(());
174        }
175
176        let mut first = true;
177
178        for part in parts {
179            match part {
180                InterpolationPart::Literal(text) => {
181                    let const_idx = self.program.add_constant(Constant::String(text));
182                    self.emit(Instruction::new(
183                        OpCode::PushConst,
184                        Some(Operand::Const(const_idx)),
185                    ));
186                }
187                InterpolationPart::Expression { expr, format_spec } => {
188                    // Parse the expression string
189                    let expr = shape_ast::parser::parse_expression_str(&expr).map_err(|e| {
190                        ShapeError::RuntimeError {
191                            message: format!(
192                                "Failed to parse expression '{}' in interpolation: {}",
193                                expr, e
194                            ),
195                            location: None,
196                        }
197                    })?;
198
199                    // Compile the expression
200                    self.compile_expr(&expr)?;
201
202                    // Format value using typed interpolation spec.
203                    self.emit_interpolation_format_call(format_spec.as_ref())?;
204                }
205            }
206
207            // Concatenate with previous result (except for first part)
208            if !first {
209                self.emit(Instruction::simple(OpCode::Add));
210            }
211            first = false;
212        }
213
214        Ok(())
215    }
216
217    /// Compile a content string expression, producing a ContentNode on the stack.
218    ///
219    /// For `c"text {expr:fg(red), bold} more"`:
220    /// 1. Push literal `text ` → MakeContentText → ContentNode::plain("text ")
221    /// 2. Compile expression, convert to string, MakeContentText,
222    ///    then ApplyContentStyle with the format spec
223    /// 3. Push literal `more` → MakeContentText → ContentNode::plain(" more")
224    /// 4. MakeContentFragment to join all parts
225    pub(in crate::compiler) fn compile_content_string_expression(
226        &mut self,
227        s: &str,
228        mode: InterpolationMode,
229    ) -> Result<()> {
230        let parts = parse_content_interpolation_with_mode(s, mode)?;
231
232        if parts.is_empty() {
233            // Empty content string → ContentNode::plain("")
234            let const_idx = self.program.add_constant(Constant::String(String::new()));
235            self.emit(Instruction::new(
236                OpCode::PushConst,
237                Some(Operand::Const(const_idx)),
238            ));
239            self.emit_content_builtin(BuiltinFunction::MakeContentText, 1)?;
240            return Ok(());
241        }
242
243        let part_count = parts.len();
244
245        for part in parts {
246            match part {
247                InterpolationPart::Literal(text) => {
248                    let const_idx = self.program.add_constant(Constant::String(text));
249                    self.emit(Instruction::new(
250                        OpCode::PushConst,
251                        Some(Operand::Const(const_idx)),
252                    ));
253                    // Convert string to ContentNode::plain(text)
254                    self.emit_content_builtin(BuiltinFunction::MakeContentText, 1)?;
255                }
256                InterpolationPart::Expression { expr, format_spec } => {
257                    // Parse and compile the expression
258                    let expr = shape_ast::parser::parse_expression_str(&expr).map_err(|e| {
259                        ShapeError::RuntimeError {
260                            message: format!(
261                                "Failed to parse expression '{}' in content string: {}",
262                                expr, e
263                            ),
264                            location: None,
265                        }
266                    })?;
267                    self.compile_expr(&expr)?;
268
269                    // Check if this has a chart spec — if so, use the chart-from-value path
270                    // instead of the normal string→text→style path
271                    if let Some(InterpolationFormatSpec::ContentStyle(ref spec)) = format_spec {
272                        if spec.chart_type.is_some() {
273                            self.emit_content_chart_from_value_args(spec)?;
274                            continue;
275                        }
276                    }
277
278                    // Convert value to string first (ToString builtin)
279                    let count = self.program.add_constant(Constant::Number(1.0));
280                    self.emit(Instruction::new(
281                        OpCode::PushConst,
282                        Some(Operand::Const(count)),
283                    ));
284                    self.emit(Instruction::new(
285                        OpCode::BuiltinCall,
286                        Some(Operand::Builtin(BuiltinFunction::FormatValueWithMeta)),
287                    ));
288
289                    // Wrap string as ContentNode::plain(text)
290                    self.emit_content_builtin(BuiltinFunction::MakeContentText, 1)?;
291
292                    // Apply content style if present
293                    if let Some(InterpolationFormatSpec::ContentStyle(ref spec)) = format_spec {
294                        self.emit_content_style_args(spec)?;
295                        self.emit_content_builtin(BuiltinFunction::ApplyContentStyle, 7)?;
296                    }
297                }
298            }
299        }
300
301        // If there are multiple parts, combine them into a Fragment
302        if part_count > 1 {
303            let count_const = self.program.add_constant(Constant::Int(part_count as i64));
304            self.emit(Instruction::new(
305                OpCode::PushConst,
306                Some(Operand::Const(count_const)),
307            ));
308            self.emit_content_builtin(BuiltinFunction::MakeContentFragment, part_count + 1)?;
309        }
310
311        Ok(())
312    }
313
314    /// Emit a content builtin call with the given arg count pushed on stack.
315    fn emit_content_builtin(&mut self, builtin: BuiltinFunction, arg_count: usize) -> Result<()> {
316        let count = self
317            .program
318            .add_constant(Constant::Number(arg_count as f64));
319        self.emit(Instruction::new(
320            OpCode::PushConst,
321            Some(Operand::Const(count)),
322        ));
323        self.emit(Instruction::new(
324            OpCode::BuiltinCall,
325            Some(Operand::Builtin(builtin)),
326        ));
327        Ok(())
328    }
329
330    /// Push ContentFormatSpec fields onto the stack as constants.
331    ///
332    /// Stack layout (7 values): [content_node, fg_color, bg_color, bold, italic, underline, dim]
333    /// Color encoding: -1 = none, 0-7 = named colors, 256+ = RGB(r*65536 + g*256 + b)
334    fn emit_content_style_args(&mut self, spec: &ContentFormatSpec) -> Result<()> {
335        // fg color
336        let fg_val = Self::encode_color_spec(&spec.fg);
337        let fg = self.program.add_constant(Constant::Int(fg_val));
338        self.emit(Instruction::new(
339            OpCode::PushConst,
340            Some(Operand::Const(fg)),
341        ));
342
343        // bg color
344        let bg_val = Self::encode_color_spec(&spec.bg);
345        let bg = self.program.add_constant(Constant::Int(bg_val));
346        self.emit(Instruction::new(
347            OpCode::PushConst,
348            Some(Operand::Const(bg)),
349        ));
350
351        // bold
352        let bold = self.program.add_constant(Constant::Bool(spec.bold));
353        self.emit(Instruction::new(
354            OpCode::PushConst,
355            Some(Operand::Const(bold)),
356        ));
357
358        // italic
359        let italic = self.program.add_constant(Constant::Bool(spec.italic));
360        self.emit(Instruction::new(
361            OpCode::PushConst,
362            Some(Operand::Const(italic)),
363        ));
364
365        // underline
366        let underline = self.program.add_constant(Constant::Bool(spec.underline));
367        self.emit(Instruction::new(
368            OpCode::PushConst,
369            Some(Operand::Const(underline)),
370        ));
371
372        // dim
373        let dim = self.program.add_constant(Constant::Bool(spec.dim));
374        self.emit(Instruction::new(
375            OpCode::PushConst,
376            Some(Operand::Const(dim)),
377        ));
378
379        Ok(())
380    }
381
382    /// Emit args for MakeContentChartFromValue builtin.
383    ///
384    /// Stack layout: [value, chart_type_str, x_column_str, y_columns_count, y_col1, y_col2, ...]
385    /// The value is already on the stack from compile_expr.
386    fn emit_content_chart_from_value_args(&mut self, spec: &ContentFormatSpec) -> Result<()> {
387        // chart_type string
388        let chart_type_str = match spec.chart_type {
389            Some(ChartTypeSpec::Line) => "line",
390            Some(ChartTypeSpec::Bar) => "bar",
391            Some(ChartTypeSpec::Scatter) => "scatter",
392            Some(ChartTypeSpec::Area) => "area",
393            Some(ChartTypeSpec::Histogram) => "histogram",
394            None => "line",
395        };
396        let ct = self
397            .program
398            .add_constant(Constant::String(chart_type_str.to_string()));
399        self.emit(Instruction::new(
400            OpCode::PushConst,
401            Some(Operand::Const(ct)),
402        ));
403
404        // x_column string (or empty string if not specified)
405        let x_col = spec.x_column.as_deref().unwrap_or("");
406        let xc = self
407            .program
408            .add_constant(Constant::String(x_col.to_string()));
409        self.emit(Instruction::new(
410            OpCode::PushConst,
411            Some(Operand::Const(xc)),
412        ));
413
414        // y_columns count
415        let y_count = self
416            .program
417            .add_constant(Constant::Int(spec.y_columns.len() as i64));
418        self.emit(Instruction::new(
419            OpCode::PushConst,
420            Some(Operand::Const(y_count)),
421        ));
422
423        // y column names
424        for y_col in &spec.y_columns {
425            let yc = self
426                .program
427                .add_constant(Constant::String(y_col.clone()));
428            self.emit(Instruction::new(
429                OpCode::PushConst,
430                Some(Operand::Const(yc)),
431            ));
432        }
433
434        // Total args: 1 (value) + 1 (chart_type) + 1 (x_col) + 1 (y_count) + N (y_cols)
435        let total_args = 4 + spec.y_columns.len();
436        self.emit_content_builtin(BuiltinFunction::MakeContentChartFromValue, total_args)?;
437
438        Ok(())
439    }
440
441    /// Encode a ColorSpec as an i64 constant.
442    /// -1 = none, 0-7 = named colors, 256+ = RGB
443    fn encode_color_spec(color: &Option<ColorSpec>) -> i64 {
444        match color {
445            None => -1,
446            Some(ColorSpec::Named(named)) => match named {
447                NamedContentColor::Red => 0,
448                NamedContentColor::Green => 1,
449                NamedContentColor::Blue => 2,
450                NamedContentColor::Yellow => 3,
451                NamedContentColor::Magenta => 4,
452                NamedContentColor::Cyan => 5,
453                NamedContentColor::White => 6,
454                NamedContentColor::Default => 7,
455            },
456            Some(ColorSpec::Rgb(r, g, b)) => {
457                256 + (*r as i64) * 65536 + (*g as i64) * 256 + (*b as i64)
458            }
459        }
460    }
461}
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466    use shape_ast::interpolation::parse_interpolation_with_mode;
467
468    fn parse_braces(s: &str) -> shape_ast::error::Result<Vec<InterpolationPart>> {
469        parse_interpolation_with_mode(s, InterpolationMode::Braces)
470    }
471
472    #[test]
473    fn test_no_interpolation() {
474        let parts = parse_braces("Hello World").unwrap();
475        assert_eq!(parts.len(), 1);
476        assert!(matches!(&parts[0], InterpolationPart::Literal(s) if s == "Hello World"));
477    }
478
479    #[test]
480    fn test_simple_interpolation() {
481        let parts = parse_braces("value: {x}").unwrap();
482        assert_eq!(parts.len(), 2);
483        assert!(matches!(&parts[0], InterpolationPart::Literal(s) if s == "value: "));
484        assert!(matches!(
485            &parts[1],
486            InterpolationPart::Expression {
487                expr,
488                format_spec: None
489            } if expr == "x"
490        ));
491    }
492
493    #[test]
494    fn test_expression_interpolation() {
495        let parts = parse_braces("sum: {x + y}").unwrap();
496        assert_eq!(parts.len(), 2);
497        assert!(matches!(&parts[0], InterpolationPart::Literal(s) if s == "sum: "));
498        assert!(matches!(
499            &parts[1],
500            InterpolationPart::Expression {
501                expr,
502                format_spec: None
503            } if expr == "x + y"
504        ));
505    }
506
507    #[test]
508    fn test_multiple_interpolations() {
509        let parts = parse_braces("a={a}, b={b}").unwrap();
510        assert_eq!(parts.len(), 4);
511        assert!(matches!(&parts[0], InterpolationPart::Literal(s) if s == "a="));
512        assert!(matches!(
513            &parts[1],
514            InterpolationPart::Expression {
515                expr,
516                format_spec: None
517            } if expr == "a"
518        ));
519        assert!(matches!(&parts[2], InterpolationPart::Literal(s) if s == ", b="));
520        assert!(matches!(
521            &parts[3],
522            InterpolationPart::Expression {
523                expr,
524                format_spec: None
525            } if expr == "b"
526        ));
527    }
528
529    #[test]
530    fn test_escaped_braces() {
531        let parts = parse_braces("Use {{x}} for literal").unwrap();
532        assert_eq!(parts.len(), 1);
533        assert!(matches!(&parts[0], InterpolationPart::Literal(s) if s == "Use {x} for literal"));
534    }
535
536    #[test]
537    fn test_as_type_in_interpolation() {
538        let parts = parse_braces("{x as Percent}").unwrap();
539        assert_eq!(parts.len(), 1);
540        assert!(matches!(
541            &parts[0],
542            InterpolationPart::Expression {
543                expr,
544                format_spec: None
545            } if expr == "x as Percent"
546        ));
547    }
548
549    #[test]
550    fn test_nested_braces_in_object() {
551        let parts = parse_braces("obj: {x.method({a: 1})}").unwrap();
552        assert_eq!(parts.len(), 2);
553        assert!(matches!(
554            &parts[1],
555            InterpolationPart::Expression {
556                expr,
557                format_spec: None
558            } if expr == "x.method({a: 1})"
559        ));
560    }
561
562    #[test]
563    fn test_interpolation_with_format_spec() {
564        let parts = parse_braces("px={price:fixed(2)}").unwrap();
565        assert_eq!(parts.len(), 2);
566        assert!(matches!(&parts[0], InterpolationPart::Literal(s) if s == "px="));
567        assert!(matches!(
568            &parts[1],
569            InterpolationPart::Expression {
570                expr,
571                format_spec: Some(spec)
572            } if expr == "price"
573                && *spec == InterpolationFormatSpec::Fixed { precision: 2 }
574        ));
575    }
576
577    #[test]
578    fn test_interpolation_does_not_split_double_colon() {
579        let parts = parse_braces("{Type::Variant}").unwrap();
580        assert_eq!(parts.len(), 1);
581        assert!(matches!(
582            &parts[0],
583            InterpolationPart::Expression {
584                expr,
585                format_spec: None
586            } if expr == "Type::Variant"
587        ));
588    }
589
590    #[test]
591    fn test_missing_format_spec_error() {
592        let result = parse_braces("value: {x:}");
593        assert!(result.is_err());
594    }
595
596    #[test]
597    fn test_unmatched_close_brace_error() {
598        let result = parse_braces("value: }");
599        assert!(result.is_err());
600    }
601
602    #[test]
603    fn test_has_interpolation() {
604        assert!(has_interpolation_with_mode(
605            "value: {x}",
606            InterpolationMode::Braces
607        ));
608        assert!(has_interpolation_with_mode(
609            "{x + y}",
610            InterpolationMode::Braces
611        ));
612        assert!(!has_interpolation_with_mode(
613            "Hello World",
614            InterpolationMode::Braces
615        ));
616        assert!(!has_interpolation_with_mode(
617            "Use {{x}} for literal",
618            InterpolationMode::Braces
619        )); // Escaped, no real interpolation
620    }
621
622    #[test]
623    fn test_empty_interpolation_error() {
624        let result = parse_braces("value: {}");
625        assert!(result.is_err());
626    }
627
628    #[test]
629    fn test_dollar_mode_interpolation() {
630        let parts =
631            parse_interpolation_with_mode("{\"name\": ${user.name}}", InterpolationMode::Dollar)
632                .unwrap();
633        assert_eq!(parts.len(), 3);
634        assert!(matches!(
635            &parts[0],
636            InterpolationPart::Literal(s) if s == "{\"name\": "
637        ));
638        assert!(matches!(
639            &parts[1],
640            InterpolationPart::Expression {
641                expr,
642                format_spec: None
643            } if expr == "user.name"
644        ));
645        assert!(matches!(&parts[2], InterpolationPart::Literal(s) if s == "}"));
646    }
647}