瀏覽代碼

String interpolation with arbitrary sub-expressions!

David Peter 2 年之前
父節點
當前提交
c354c0995f

+ 1 - 1
book/build.sh

@@ -36,7 +36,7 @@ generate_example pipe_flow_rate "Flow rate in a pipe" true
 generate_example recipe "Recipe" true
 generate_example acidity "Acidity" true
 generate_example factorial "Factorial" false
-generate_example xkcd_2585 "XKCD 2585" false
+generate_example xkcd_2585 "XKCD 2585" true
 generate_example xkcd_2812 "XKCD 2812" true
 
 generate_example numbat_syntax "Syntax overview" false

+ 1 - 1
book/src/example-barometric_formula.md

@@ -15,5 +15,5 @@ let lapse_rate: TemperatureGradient = 0.65 K / 100 m
 fn air_pressure(height: Length) -> Pressure =
     p0 · (1 - lapse_rate · height / t0)^5.255
 
-print(air_pressure(1500 m))
+print("Air pressure 1500 m above sea level: {air_pressure(1500 m) -> hPa}")
 ```

+ 1 - 1
book/src/example-medication_dosage.md

@@ -17,5 +17,5 @@ let total_daily_dose = dosage * body_weight -> mg / day
 print("Total daily dose: {total_daily_dose}")
 
 let single_dose = total_daily_dose / frequency
-print("Single dose: {single_dose}")
+print("Single dose:      {single_dose}")
 ```

+ 3 - 3
book/src/example-musical_note_frequency.md

@@ -9,7 +9,7 @@ let frequency_A4: Frequency = 440 Hz  # the A above middle C, A4
 
 fn note_frequency(n: Scalar) -> Frequency = frequency_A4 * 2^(n / 12)
 
-print(note_frequency(12))  # one octave higher up (A5), 880 Hz
-print(note_frequency(7))   # E4
-print(note_frequency(-3))  # C4
+print("A5: {note_frequency(12)}")  # one octave higher up, 880 Hz
+print("E4: {note_frequency(7)}")
+print("C4: {note_frequency(-3)}")
 ```

+ 1 - 0
book/src/example-numbat_syntax.md

@@ -102,6 +102,7 @@ fn step(x: Scalar) -> Scalar =   # The construct 'if <cond> then <expr> else <ex
 print(2 kilowarhol)              # Print the value of an expression
 print("hello world")             # Print a message
 print("value of pi = {pi}")      # String interpolation
+print("sqrt(10) = {sqrt(10)}")   # Expressions in string interpolation
 assert_eq(1 ft, 12 in)           # Assert that two quantities are equal
 assert_eq(1 yd, 1 m, 10 cm)      # Assert that two quantities are equal, up to
                                  # the given precision

+ 2 - 2
book/src/example-pipe_flow_rate.md

@@ -17,6 +17,6 @@ let pipe_radius = 1 cm
 let pipe_length = 10 m
 let Δp = 0.1 bar
 
-let Q: FlowRate = flow_rate(pipe_radius, pipe_length, Δp) -> L/s
-print(Q)
+let Q = flow_rate(pipe_radius, pipe_length, Δp)
+print("Flow rate: {Q -> L/s}")
 ```

+ 4 - 11
book/src/example-recipe.md

@@ -14,15 +14,8 @@ let desired_servings = 3 servings
 fn scale<D>(quantity: D) -> D =
     quantity × desired_servings / original_recipe_servings
 
-let milk = 500 ml
-print(scale(milk))
-
-let flour = 250 g
-print(scale(flour))
-
-let sugar = 2 cups
-print(scale(sugar))
-
-let baking_powder = 4 tablespoons
-print(scale(baking_powder))
+print("Milk:          {scale(500 ml)}")
+print("Flour:         {scale(250 g)}")
+print("Sugar:         {scale(2 cups)}")
+print("Baking powder: {scale(4 tablespoons)}")
 ```

+ 2 - 4
book/src/example-xkcd_2585.md

@@ -4,9 +4,6 @@
 
 ``` numbat
 # https://xkcd.com/2585/
-#
-# I can ride my bike at 45 mph.
-# If you round.
 
 17 mph
 
@@ -34,5 +31,6 @@ ans -> knots         // round
 ans -> furlongs/min  // round
 ans -> mph           // round
 
-assert_eq(ans, 45 mph)
+print("I can ride my bike at {ans}.")
+print("If you round.")
 ```

+ 3 - 2
book/src/example-xkcd_2812.md

@@ -23,13 +23,14 @@ let panel_efficiency = 20 %
 fn savings(i: Irradiance) -> Money / Time =
     net_metering_rate × i × panel_area × panel_efficiency -> $/year
 
-## Option A: On the roof, south facing
+print("Option A: On the roof, south facing")
 
 let savings_a = savings(4 kWh/m²/day)
 
 print(savings_a // round)
 
-## Option B: On the sun, downward facing
+print()
+print("Option B: On the sun, downward facing")
 
 dimension Luminosity = Power
 

+ 7 - 0
book/src/procedures.md

@@ -17,6 +17,13 @@ let radius: Length = sqrt(footballfield / 4 pi) -> meter
 print("A football field would fit on a sphere of radius {radius}")
 ```
 
+You can use almost every expression inside a string interpolation field. For example:
+
+```nbt
+let speed = 25 km/h
+print("Speed of the bicycle: {speed} ({speed -> mph})")
+```
+
 ## Testing
 
 The `assert_eq` procedure can be used to test for (approximate) equality of two quantities.

+ 1 - 1
examples/barometric_formula.nbt

@@ -10,5 +10,5 @@ let lapse_rate: TemperatureGradient = 0.65 K / 100 m
 fn air_pressure(height: Length) -> Pressure =
     p0 · (1 - lapse_rate · height / t0)^5.255
 
-print(air_pressure(1500 m))
+print("Air pressure 1500 m above sea level: {air_pressure(1500 m) -> hPa}")
 assert_eq(air_pressure(1500 m), 845.586 hPa, 0.1 hPa)

+ 4 - 7
examples/format_time.nbt

@@ -1,12 +1,9 @@
 let time = 17.47 hours
 
-let time_seconds = time -> seconds
-let num_seconds = mod(time_seconds, 60 seconds)
-let num_minutes = mod(time - num_seconds, 60 minutes) -> minutes
+let num_seconds = mod(time -> seconds, 60 seconds)
+let num_minutes = mod(time - num_seconds, 60 minutes) -> minutes // floor
 let num_hours = floor((time - num_minutes - num_seconds) / 1 hour) × hour -> hours
 
-print(num_hours)
-print(num_minutes)
-print(num_seconds)
+assert_eq(num_hours + num_minutes + num_seconds -> s, time -> s, 1ms)
 
-assert_eq(num_hours + num_minutes + num_seconds, time)
+print("{num_hours/h}:{num_minutes/min}:{num_seconds/s}")

+ 1 - 1
examples/medication_dosage.nbt

@@ -13,5 +13,5 @@ print("Total daily dose: {total_daily_dose}")
 assert_eq(total_daily_dose, 4500 mg/day)
 
 let single_dose = total_daily_dose / frequency
-print("Single dose: {single_dose}")
+print("Single dose:      {single_dose}")
 assert_eq(single_dose, 1500 mg/taking)

+ 3 - 3
examples/musical_note_frequency.nbt

@@ -4,9 +4,9 @@ let frequency_A4: Frequency = 440 Hz  # the A above middle C, A4
 
 fn note_frequency(n: Scalar) -> Frequency = frequency_A4 * 2^(n / 12)
 
-print(note_frequency(12))  # one octave higher up (A5), 880 Hz
-print(note_frequency(7))   # E4
-print(note_frequency(-3))  # C4
+print("A5: {note_frequency(12)}")  # one octave higher up, 880 Hz
+print("E4: {note_frequency(7)}")
+print("C4: {note_frequency(-3)}")
 assert_eq(note_frequency(12), 2 * frequency_A4)
 assert_eq(note_frequency(7),  659.255 Hz, 1 mHz)
 assert_eq(note_frequency(-3), 369.994 Hz, 1 mHz)

+ 1 - 0
examples/numbat_syntax.nbt

@@ -97,6 +97,7 @@ fn step(x: Scalar) -> Scalar =   # The construct 'if <cond> then <expr> else <ex
 print(2 kilowarhol)              # Print the value of an expression
 print("hello world")             # Print a message
 print("value of pi = {pi}")      # String interpolation
+print("sqrt(10) = {sqrt(10)}")   # Expressions in string interpolation
 assert_eq(1 ft, 12 in)           # Assert that two quantities are equal
 assert_eq(1 yd, 1 m, 10 cm)      # Assert that two quantities are equal, up to
                                  # the given precision

+ 2 - 2
examples/pipe_flow_rate.nbt

@@ -12,6 +12,6 @@ let pipe_radius = 1 cm
 let pipe_length = 10 m
 let Δp = 0.1 bar
 
-let Q: FlowRate = flow_rate(pipe_radius, pipe_length, Δp) -> L/s
-print(Q)
+let Q = flow_rate(pipe_radius, pipe_length, Δp)
+print("Flow rate: {Q -> L/s}")
 assert_eq(Q, 3.93 L/s, 0.01 L/s)

+ 1 - 0
examples/print.nbt

@@ -2,3 +2,4 @@ print(1)
 print(2 meter)
 print("hello world")
 print("pi = {pi}")
+print("1 + 2 = {1 + 2}")

+ 4 - 11
examples/recipe.nbt

@@ -9,14 +9,7 @@ let desired_servings = 3 servings
 fn scale<D>(quantity: D) -> D =
     quantity × desired_servings / original_recipe_servings
 
-let milk = 500 ml
-print(scale(milk))
-
-let flour = 250 g
-print(scale(flour))
-
-let sugar = 2 cups
-print(scale(sugar))
-
-let baking_powder = 4 tablespoons
-print(scale(baking_powder))
+print("Milk:          {scale(500 ml)}")
+print("Flour:         {scale(250 g)}")
+print("Sugar:         {scale(2 cups)}")
+print("Baking powder: {scale(4 tablespoons)}")

+ 2 - 1
examples/what_if_11.nbt

@@ -14,4 +14,5 @@ let earth_radius = 6371 km
 
 let frequency = (300 billion birds / 4 pi earth_radius^2) × (1 poop / bird / hour) × (16 hours / day) × (1 mouth / poop) × (15 cm^2 / mouth)
 let period = 1 / frequency
-print(period -> years)
+
+print("It happens once every ~{period -> year}s")

+ 4 - 4
examples/what_if_158.nbt

@@ -21,7 +21,7 @@ let decay_rate: Activity = ln(2) / halflife
 let radioactivity: Activity / Mass =
     N_A × occurence_40K × decay_rate / molar_mass -> Bq / g
 
-print(radioactivity)
+print("Radioactivity of potassium: {radioactivity}")
 
 # Next, we come to bananas
 
@@ -35,7 +35,7 @@ let potassium_per_banana = 451 mg / banana
 let radioactivity_banana: Activity / Banana =
     potassium_per_banana × radioactivity -> Bq / banana
 
-print(radioactivity_banana)
+print("Radioactivity of a banana: {radioactivity_banana}")
 
 # A single 40-K decay releases an energy of
 # (https://commons.wikimedia.org/wiki/File:Potassium-40-decay-scheme.svg)
@@ -47,7 +47,7 @@ let energy_per_decay: Energy = 11 percent × 1.5 MeV + 89 percent × 1.3 MeV
 let power_per_banana: Power / Banana =
     radioactivity_banana × energy_per_decay -> pW / banana
 
-print(power_per_banana)
+print("Power per banana: {power_per_banana}")
 
 unit household
 
@@ -57,7 +57,7 @@ let power_consumption_household: Power / Household =
 let bananas_per_household =
     power_consumption_household / power_per_banana -> bananas / household
 
-print(bananas_per_household)
+print("Bananas per household: {bananas_per_household}")
 
 # TODO: https://what-if.xkcd.com/158/ says this number should be around
 # 300 quadrillion, but we only get 0.1 quadrillion. 300 quadrillion

+ 2 - 3
examples/xkcd_2585.nbt

@@ -1,7 +1,4 @@
 # https://xkcd.com/2585/
-#
-# I can ride my bike at 45 mph.
-# If you round.
 
 17 mph
 
@@ -29,4 +26,6 @@ ans -> knots         // round
 ans -> furlongs/min  // round
 ans -> mph           // round
 
+print("I can ride my bike at {ans}.")
+print("If you round.")
 assert_eq(ans, 45 mph)

+ 3 - 2
examples/xkcd_2812.nbt

@@ -18,14 +18,15 @@ let panel_efficiency = 20 %
 fn savings(i: Irradiance) -> Money / Time =
     net_metering_rate × i × panel_area × panel_efficiency -> $/year
 
-## Option A: On the roof, south facing
+print("Option A: On the roof, south facing")
 
 let savings_a = savings(4 kWh/m²/day)
 
 print(savings_a // round)
 assert_eq(savings_a, 58 $/year, 1 $/year)
 
-## Option B: On the sun, downward facing
+print()
+print("Option B: On the sun, downward facing")
 
 dimension Luminosity = Power
 

+ 3 - 20
numbat/src/ast.rs

@@ -52,24 +52,7 @@ impl PrettyPrint for BinaryOperator {
 #[derive(Debug, Clone, PartialEq)]
 pub enum StringPart {
     Fixed(String),
-    Interpolation(Span, String),
-}
-
-impl PrettyPrint for StringPart {
-    fn pretty_print(&self) -> Markup {
-        match self {
-            StringPart::Fixed(s) => s.pretty_print(),
-            StringPart::Interpolation(_, identifier) => {
-                m::operator("{") + m::identifier(identifier) + m::operator("}")
-            }
-        }
-    }
-}
-
-impl PrettyPrint for &Vec<StringPart> {
-    fn pretty_print(&self) -> Markup {
-        m::operator("\"") + self.iter().map(|p| p.pretty_print()).sum() + m::operator("\"")
-    }
+    Interpolation(Span, Box<Expression>),
 }
 
 #[derive(Debug, Clone, PartialEq)]
@@ -378,8 +361,8 @@ impl ReplaceSpans for StringPart {
     fn replace_spans(&self) -> Self {
         match self {
             f @ StringPart::Fixed(_) => f.clone(),
-            StringPart::Interpolation(_, identifier) => {
-                StringPart::Interpolation(Span::dummy(), identifier.clone())
+            StringPart::Interpolation(_, expr) => {
+                StringPart::Interpolation(Span::dummy(), Box::new(expr.replace_spans()))
             }
         }
     }

+ 10 - 14
numbat/src/bytecode_interpreter.rs

@@ -1,12 +1,12 @@
 use std::collections::HashMap;
 
-use crate::ast::{ProcedureKind, StringPart};
+use crate::ast::ProcedureKind;
 use crate::interpreter::{
     Interpreter, InterpreterResult, InterpreterSettings, Result, RuntimeError,
 };
 use crate::prefix::Prefix;
 use crate::pretty_print::PrettyPrint;
-use crate::typed_ast::{BinaryOperator, Expression, Statement, UnaryOperator};
+use crate::typed_ast::{BinaryOperator, Expression, Statement, StringPart, UnaryOperator};
 use crate::unit::Unit;
 use crate::unit_registry::UnitRegistry;
 use crate::vm::{Constant, ExecutionContext, Op, Vm};
@@ -21,15 +21,6 @@ pub struct BytecodeInterpreter {
 }
 
 impl BytecodeInterpreter {
-    fn compile_load_identifier(&mut self, identifier: &str) {
-        if let Some(position) = self.local_variables.iter().position(|n| n == identifier) {
-            self.vm.add_op1(Op::GetLocal, position as u16); // TODO: check overflow
-        } else {
-            let identifier_idx = self.vm.add_global_identifier(identifier, None);
-            self.vm.add_op1(Op::GetVariable, identifier_idx);
-        }
-    }
-
     fn compile_expression(&mut self, expr: &Expression) -> Result<()> {
         match expr {
             Expression::Scalar(_span, n) => {
@@ -37,7 +28,12 @@ impl BytecodeInterpreter {
                 self.vm.add_op1(Op::LoadConstant, index);
             }
             Expression::Identifier(_span, identifier, _type) => {
-                self.compile_load_identifier(identifier);
+                if let Some(position) = self.local_variables.iter().position(|n| n == identifier) {
+                    self.vm.add_op1(Op::GetLocal, position as u16); // TODO: check overflow
+                } else {
+                    let identifier_idx = self.vm.add_global_identifier(identifier, None);
+                    self.vm.add_op1(Op::GetVariable, identifier_idx);
+                }
             }
             Expression::UnitIdentifier(_span, prefix, unit_name, _full_name, _type) => {
                 let index = self
@@ -106,8 +102,8 @@ impl BytecodeInterpreter {
                             let index = self.vm.add_constant(Constant::String(s.clone()));
                             self.vm.add_op1(Op::LoadConstant, index)
                         }
-                        StringPart::Interpolation(_, identifier) => {
-                            self.compile_load_identifier(identifier);
+                        StringPart::Interpolation(_, expr) => {
+                            self.compile_expression_with_simplify(expr)?;
                         }
                     }
                 }

+ 9 - 5
numbat/src/ffi.rs

@@ -37,7 +37,7 @@ pub(crate) fn procedures() -> &'static HashMap<ProcedureKind, ForeignFunction> {
             ProcedureKind::Print,
             ForeignFunction {
                 name: "print".into(),
-                arity: 1..=1,
+                arity: 0..=1,
                 callable: Callable::Procedure(print),
             },
         );
@@ -296,11 +296,15 @@ pub(crate) fn functions() -> &'static HashMap<String, ForeignFunction> {
 }
 
 fn print(ctx: &mut ExecutionContext, args: &[Value]) -> ControlFlow {
-    assert!(args.len() == 1);
+    assert!(args.len() <= 1);
 
-    match &args[0] {
-        Value::String(string) => (ctx.print_fn)(&crate::markup::text(string)), // print string without quotes
-        arg => (ctx.print_fn)(&arg.pretty_print()),
+    if args.len() == 0 {
+        (ctx.print_fn)(&crate::markup::text(""))
+    } else {
+        match &args[0] {
+            Value::String(string) => (ctx.print_fn)(&crate::markup::text(string)), // print string without quotes
+            arg => (ctx.print_fn)(&arg.pretty_print()),
+        }
     }
 
     ControlFlow::Continue(())

+ 63 - 103
numbat/src/parser.rs

@@ -36,7 +36,7 @@ use crate::decorator::Decorator;
 use crate::number::Number;
 use crate::prefix_parser::AcceptsPrefix;
 use crate::resolver::ModulePath;
-use crate::span::{SourceCodePositition, Span};
+use crate::span::Span;
 use crate::tokenizer::{Token, TokenKind, TokenizerError, TokenizerErrorKind};
 
 use num_traits::{CheckedDiv, FromPrimitive, Zero};
@@ -154,9 +154,6 @@ pub enum ParseErrorKind {
 
     #[error("Expected 'else' in if-then-else condition")]
     ExpectedElse,
-
-    #[error("Unfinished string-interpolation field")]
-    UnfinishedStringInterpolationField,
 }
 
 #[derive(Debug, Clone, Error)]
@@ -1022,15 +1019,42 @@ impl<'a> Parser<'a> {
                     false
                 },
             ))
-        } else if let Some(token) = self.match_exact(TokenKind::String) {
+        } else if let Some(token) = self.match_exact(TokenKind::StringFixed) {
             Ok(Expression::String(
                 token.span,
-                parse_string_interpolation(
-                    token.span.start,
-                    token.span.code_source_id,
-                    &token.lexeme,
-                )?,
+                vec![StringPart::Fixed(strip_first_and_last(&token.lexeme))],
             ))
+        } else if let Some(token) = self.match_exact(TokenKind::StringInterpolationStart) {
+            let mut parts = vec![StringPart::Fixed(strip_first_and_last(&token.lexeme))];
+
+            let expr = self.expression()?;
+            parts.push(StringPart::Interpolation(expr.full_span(), Box::new(expr)));
+
+            while let Some(inner_token) = self.match_any(&[
+                TokenKind::StringInterpolationMiddle,
+                TokenKind::StringInterpolationEnd,
+            ]) {
+                match inner_token.kind {
+                    TokenKind::StringInterpolationMiddle => {
+                        parts.push(StringPart::Fixed(strip_first_and_last(&inner_token.lexeme)));
+
+                        let expr = self.expression()?;
+                        parts.push(StringPart::Interpolation(expr.full_span(), Box::new(expr)));
+                    }
+                    TokenKind::StringInterpolationEnd => {
+                        parts.push(StringPart::Fixed(strip_first_and_last(&inner_token.lexeme)));
+                        break;
+                    }
+                    _ => unreachable!(),
+                }
+            }
+
+            parts = parts
+                .into_iter()
+                .filter(|p| !matches!(p, StringPart::Fixed(s) if s.is_empty()))
+                .collect();
+
+            Ok(Expression::String(token.span, parts))
         } else if self.match_exact(TokenKind::LeftParen).is_some() {
             let inner = self.expression()?;
 
@@ -1267,89 +1291,8 @@ impl<'a> Parser<'a> {
     }
 }
 
-fn parse_string_interpolation(
-    pos: SourceCodePositition,
-    code_source_id: usize,
-    string: &str,
-) -> Result<Vec<StringPart>> {
-    let mut parts = vec![];
-    let mut pos = pos;
-
-    let mut last_pos = pos;
-
-    let mut chars = string.chars();
-    let mut advance = || -> Option<(char, SourceCodePositition)> {
-        if let Some(c) = chars.next() {
-            pos.byte += c.len_utf8() as u32;
-            pos.position += 1;
-            Some((c, pos))
-        } else {
-            None
-        }
-    };
-
-    let mut lexeme = String::new();
-    let (_, mut lexeme_start) = advance().unwrap(); // Skip the initial quote for the beginning of the string
-
-    let mut in_fixed_mode = true;
-
-    loop {
-        if let Some((c, current_pos)) = advance() {
-            if in_fixed_mode {
-                if c == '"' {
-                    parts.push(StringPart::Fixed(lexeme.clone()));
-                    break;
-                } else if c == '{' {
-                    parts.push(StringPart::Fixed(lexeme.clone()));
-
-                    lexeme_start = current_pos;
-                    lexeme.clear();
-
-                    in_fixed_mode = false;
-                } else {
-                    lexeme.push(c);
-                }
-            } else {
-                if c == '}' {
-                    let span = Span {
-                        start: lexeme_start,
-                        end: last_pos,
-                        code_source_id,
-                    };
-
-                    parts.push(StringPart::Interpolation(span, lexeme.clone()));
-
-                    lexeme_start = current_pos;
-                    lexeme.clear();
-
-                    in_fixed_mode = true;
-                } else if c == '"' {
-                    let span = Span {
-                        start: lexeme_start,
-                        end: last_pos,
-                        code_source_id,
-                    };
-                    return Err(ParseError {
-                        kind: ParseErrorKind::UnfinishedStringInterpolationField,
-                        span,
-                    });
-                } else {
-                    lexeme.push(c);
-                }
-            }
-
-            last_pos = current_pos;
-        } else {
-            break;
-        }
-    }
-
-    parts = parts
-        .into_iter()
-        .filter(|p| !matches!(p, StringPart::Fixed(s) if s.is_empty()))
-        .collect();
-
-    Ok(parts)
+fn strip_first_and_last(s: &str) -> String {
+    (&s[1..(s.len() - 1)]).to_string()
 }
 
 pub fn parse(input: &str, code_source_id: usize) -> Result<Vec<Statement>> {
@@ -2187,14 +2130,19 @@ mod tests {
     }
 
     #[test]
-    fn string_interpolation() {
+    fn strings() {
+        parse_as_expression(
+            &["\"hello world\""],
+            Expression::String(Span::dummy(), vec![StringPart::Fixed("hello world".into())]),
+        );
+
         parse_as_expression(
             &["\"pi = {pi}\""],
             Expression::String(
                 Span::dummy(),
                 vec![
                     StringPart::Fixed("pi = ".into()),
-                    StringPart::Interpolation(Span::dummy(), "pi".into()),
+                    StringPart::Interpolation(Span::dummy(), Box::new(identifier!("pi"))),
                 ],
             ),
         );
@@ -2203,7 +2151,10 @@ mod tests {
             &["\"{pi}\""],
             Expression::String(
                 Span::dummy(),
-                vec![StringPart::Interpolation(Span::dummy(), "pi".into())],
+                vec![StringPart::Interpolation(
+                    Span::dummy(),
+                    Box::new(identifier!("pi")),
+                )],
             ),
         );
 
@@ -2212,8 +2163,8 @@ mod tests {
             Expression::String(
                 Span::dummy(),
                 vec![
-                    StringPart::Interpolation(Span::dummy(), "pi".into()),
-                    StringPart::Interpolation(Span::dummy(), "e".into()),
+                    StringPart::Interpolation(Span::dummy(), Box::new(identifier!("pi"))),
+                    StringPart::Interpolation(Span::dummy(), Box::new(identifier!("e"))),
                 ],
             ),
         );
@@ -2223,16 +2174,25 @@ mod tests {
             Expression::String(
                 Span::dummy(),
                 vec![
-                    StringPart::Interpolation(Span::dummy(), "pi".into()),
+                    StringPart::Interpolation(Span::dummy(), Box::new(identifier!("pi"))),
                     StringPart::Fixed(" + ".into()),
-                    StringPart::Interpolation(Span::dummy(), "e".into()),
+                    StringPart::Interpolation(Span::dummy(), Box::new(identifier!("e"))),
                 ],
             ),
         );
 
-        should_fail_with(
-            &["\"pi = {pi\"", "\"pi = {\"", "\"pi = {pi}, e = {e\""],
-            ParseErrorKind::UnfinishedStringInterpolationField,
+        parse_as_expression(
+            &["\"1 + 2 = {1 + 2}\""],
+            Expression::String(
+                Span::dummy(),
+                vec![
+                    StringPart::Fixed("1 + 2 = ".into()),
+                    StringPart::Interpolation(
+                        Span::dummy(),
+                        Box::new(binop!(scalar!(1.0), Add, scalar!(2.0))),
+                    ),
+                ],
+            ),
         );
     }
 }

+ 14 - 2
numbat/src/prefix_transformer.rs

@@ -1,5 +1,5 @@
 use crate::{
-    ast::{Expression, Statement},
+    ast::{Expression, Statement, StringPart},
     decorator::{self, Decorator},
     name_resolution::NameResolutionError,
     prefix_parser::{PrefixParser, PrefixParserResult},
@@ -79,7 +79,19 @@ impl Transformer {
                 Box::new(self.transform_expression(*then)),
                 Box::new(self.transform_expression(*else_)),
             ),
-            expr @ Expression::String(_, _) => expr,
+            Expression::String(span, parts) => Expression::String(
+                span,
+                parts
+                    .into_iter()
+                    .map(|p| match p {
+                        f @ StringPart::Fixed(_) => f,
+                        StringPart::Interpolation(span, expr) => StringPart::Interpolation(
+                            span,
+                            Box::new(self.transform_expression(*expr)),
+                        ),
+                    })
+                    .collect(),
+            ),
         }
     }
 

+ 155 - 18
numbat/src/tokenizer.rs

@@ -29,6 +29,12 @@ pub enum TokenizerErrorKind {
 
     #[error("Unterminated string")]
     UnterminatedString,
+
+    #[error("Unterminated {{...}} interpolation in this string")]
+    UnterminatedStringInterpolation,
+
+    #[error("Unexpected '{{' inside string interpolation")]
+    UnexpectedCurlyInInterpolation,
 }
 
 #[derive(Debug, Error, PartialEq, Eq)]
@@ -100,7 +106,15 @@ pub enum TokenKind {
     Number,
     IntegerWithBase(usize),
     Identifier,
-    String,
+
+    // A normal string without interpolation: `"hello world"`
+    StringFixed,
+    // A part of a string which *starts* an interpolation: `"foo = {`
+    StringInterpolationStart,
+    // A part of a string between two interpolations: `}, and bar = {`
+    StringInterpolationMiddle,
+    // A part of a string which ends an interpolation: `}."`
+    StringInterpolationEnd,
 
     // Other
     Newline,
@@ -114,16 +128,6 @@ pub struct Token {
     pub span: Span,
 }
 
-struct Tokenizer {
-    input: Vec<char>,
-    current: SourceCodePositition,
-    last: SourceCodePositition,
-    token_start: SourceCodePositition,
-    current_index: usize,
-    token_start_index: usize,
-    code_source_id: usize,
-}
-
 fn is_exponent_char(c: char) -> bool {
     matches!(c, '¹' | '²' | '³' | '⁴' | '⁵' | '⁶' | '⁷' | '⁸' | '⁹')
 }
@@ -178,6 +182,37 @@ fn is_identifier_continue(c: char) -> bool {
         && c != '·'
 }
 
+/// When scanning a string interpolation like `"foo = {foo}, and bar = {bar}."`,
+/// the tokenizer needs to keep track of where it currently is, because we allow
+/// for (almost) arbitrary expressions inside the {…} part.
+enum InterpolationState {
+    /// We are not inside curly braces.
+    Outside,
+    /// We are currently scanning the inner part of an interpolation.
+    Inside,
+}
+
+impl InterpolationState {
+    fn is_inside(&self) -> bool {
+        matches!(self, InterpolationState::Inside)
+    }
+}
+
+struct Tokenizer {
+    input: Vec<char>,
+    current: SourceCodePositition,
+    last: SourceCodePositition,
+    token_start: SourceCodePositition,
+    current_index: usize,
+    token_start_index: usize,
+    code_source_id: usize,
+
+    // Special fields / state for parsing string interpolations
+    string_start: SourceCodePositition,
+    interpolation_start: SourceCodePositition,
+    interpolation_state: InterpolationState,
+}
+
 impl Tokenizer {
     fn new(input: &str, code_source_id: usize) -> Self {
         Tokenizer {
@@ -188,6 +223,9 @@ impl Tokenizer {
             current_index: 0,
             token_start_index: 0,
             code_source_id,
+            string_start: SourceCodePositition::start(),
+            interpolation_start: SourceCodePositition::start(),
+            interpolation_state: InterpolationState::Outside,
         }
     }
 
@@ -431,24 +469,70 @@ impl Tokenizer {
                 TokenKind::UnicodeExponent
             }
             '°' => TokenKind::Identifier, // '°' is not an alphanumeric character, so we treat it as a special case here
-            '"' => {
-                while self.peek().map(|c| c != '"').unwrap_or(false) {
+            '"' => match self.interpolation_state {
+                InterpolationState::Outside => {
+                    self.string_start = self.token_start;
+
+                    while self.peek().map(|c| c != '"' && c != '{').unwrap_or(false) {
+                        self.advance();
+                    }
+
+                    if self.match_char('"') {
+                        TokenKind::StringFixed
+                    } else if self.match_char('{') {
+                        self.interpolation_state = InterpolationState::Inside;
+                        self.interpolation_start = self.last;
+                        TokenKind::StringInterpolationStart
+                    } else {
+                        return Err(TokenizerError {
+                            kind: TokenizerErrorKind::UnterminatedString,
+                            span: Span {
+                                start: self.token_start,
+                                end: self.current,
+                                code_source_id: self.code_source_id,
+                            },
+                        });
+                    }
+                }
+                InterpolationState::Inside => {
+                    return Err(TokenizerError {
+                        kind: TokenizerErrorKind::UnterminatedStringInterpolation,
+                        span: Span {
+                            start: self.string_start,
+                            end: self.current,
+                            code_source_id: self.code_source_id,
+                        },
+                    });
+                }
+            },
+            '}' if self.interpolation_state.is_inside() => {
+                while self.peek().map(|c| c != '"' && c != '{').unwrap_or(false) {
                     self.advance();
                 }
 
                 if self.match_char('"') {
-                    TokenKind::String
+                    self.interpolation_state = InterpolationState::Outside;
+                    TokenKind::StringInterpolationEnd
+                } else if self.match_char('{') {
+                    self.interpolation_start = self.last;
+                    TokenKind::StringInterpolationMiddle
                 } else {
                     return Err(TokenizerError {
                         kind: TokenizerErrorKind::UnterminatedString,
                         span: Span {
-                            start: self.token_start,
-                            end: self.last,
+                            start: self.string_start,
+                            end: self.current,
                             code_source_id: self.code_source_id,
                         },
                     });
                 }
             }
+            '{' if self.interpolation_state.is_inside() => {
+                return Err(TokenizerError {
+                    kind: TokenizerErrorKind::UnexpectedCurlyInInterpolation,
+                    span: self.last.single_character_span(code_source_id),
+                });
+            }
             '…' => TokenKind::Ellipsis,
             c if is_identifier_start(c) => {
                 while self.peek().map(is_identifier_continue).unwrap_or(false) {
@@ -639,12 +723,65 @@ fn test_tokenize_string() {
     assert_eq!(
         tokenize_reduced("\"foo\""),
         [
-            ("\"foo\"".to_string(), String, (1, 1)),
+            ("\"foo\"".to_string(), StringFixed, (1, 1)),
             ("".to_string(), Eof, (1, 6))
         ]
     );
 
-    assert!(tokenize("\"foo", 0).is_err());
+    assert_eq!(
+        tokenize_reduced("\"foo = {foo}\""),
+        [
+            ("\"foo = {".to_string(), StringInterpolationStart, (1, 1)),
+            ("foo".to_string(), Identifier, (1, 9)),
+            ("}\"".to_string(), StringInterpolationEnd, (1, 12)),
+            ("".to_string(), Eof, (1, 14))
+        ]
+    );
+
+    assert_eq!(
+        tokenize_reduced("\"foo = {foo}, and bar = {bar}\""),
+        [
+            ("\"foo = {".to_string(), StringInterpolationStart, (1, 1)),
+            ("foo".to_string(), Identifier, (1, 9)),
+            (
+                "}, and bar = {".to_string(),
+                StringInterpolationMiddle,
+                (1, 12)
+            ),
+            ("bar".to_string(), Identifier, (1, 26)),
+            ("}\"".to_string(), StringInterpolationEnd, (1, 29)),
+            ("".to_string(), Eof, (1, 31))
+        ]
+    );
+
+    assert_eq!(
+        tokenize_reduced("\"1 + 2 = {1 + 2}\""),
+        [
+            ("\"1 + 2 = {".to_string(), StringInterpolationStart, (1, 1)),
+            ("1".to_string(), Number, (1, 11)),
+            ("+".to_string(), Plus, (1, 13)),
+            ("2".to_string(), Number, (1, 15)),
+            ("}\"".to_string(), StringInterpolationEnd, (1, 16)),
+            ("".to_string(), Eof, (1, 18))
+        ]
+    );
+
+    assert_eq!(
+        tokenize("\"foo", 0).unwrap_err().kind,
+        TokenizerErrorKind::UnterminatedString
+    );
+    assert_eq!(
+        tokenize("\"foo = {foo\"", 0).unwrap_err().kind,
+        TokenizerErrorKind::UnterminatedStringInterpolation
+    );
+    assert_eq!(
+        tokenize("\"foo = {foo}.", 0).unwrap_err().kind,
+        TokenizerErrorKind::UnterminatedString
+    );
+    assert_eq!(
+        tokenize("\"foo = {foo, bar = {bar}\"", 0).unwrap_err().kind,
+        TokenizerErrorKind::UnexpectedCurlyInInterpolation
+    );
 }
 
 #[test]

+ 13 - 15
numbat/src/typechecker.rs

@@ -721,23 +721,21 @@ impl TypeChecker {
                 )
             }
             ast::Expression::Boolean(span, val) => typed_ast::Expression::Boolean(*span, *val),
-            ast::Expression::String(span, string_parts) => {
-                for part in string_parts {
-                    if let StringPart::Interpolation(span, identifier) = part {
-                        let (_, kind) = self.identifier_type_and_kind(*span, identifier)?; // Make sure identifier exists
-                        if kind != &IdentifierKind::Variable {
-                            // String interpolation only works for variables, so far
-                            return Err(TypeCheckError::UnknownIdentifier(
+            ast::Expression::String(span, parts) => typed_ast::Expression::String(
+                *span,
+                parts
+                    .into_iter()
+                    .map(|p| match p {
+                        StringPart::Fixed(s) => Ok(typed_ast::StringPart::Fixed(s.clone())),
+                        StringPart::Interpolation(span, expr) => {
+                            Ok(typed_ast::StringPart::Interpolation(
                                 *span,
-                                identifier.clone(),
-                                None,
-                            ));
+                                Box::new(self.check_expression(expr)?),
+                            ))
                         }
-                    }
-                }
-
-                typed_ast::Expression::String(*span, string_parts.clone())
-            }
+                    })
+                    .collect::<Result<_>>()?,
+            ),
             ast::Expression::Condition(span, condition, then, else_) => {
                 let condition = self.check_expression(condition)?;
                 if condition.get_type() != Type::Boolean {

+ 24 - 1
numbat/src/typed_ast.rs

@@ -1,7 +1,7 @@
 use itertools::Itertools;
 
+use crate::ast::ProcedureKind;
 pub use crate::ast::{BinaryOperator, DimensionExpression, UnaryOperator};
-use crate::ast::{ProcedureKind, StringPart};
 use crate::dimension::DimensionRegistry;
 use crate::markup as m;
 use crate::{
@@ -72,6 +72,29 @@ impl Type {
     }
 }
 
+#[derive(Debug, Clone, PartialEq)]
+pub enum StringPart {
+    Fixed(String),
+    Interpolation(Span, Box<Expression>),
+}
+
+impl PrettyPrint for StringPart {
+    fn pretty_print(&self) -> Markup {
+        match self {
+            StringPart::Fixed(s) => s.pretty_print(),
+            StringPart::Interpolation(_, expr) => {
+                m::operator("{") + expr.pretty_print() + m::operator("}")
+            }
+        }
+    }
+}
+
+impl PrettyPrint for &Vec<StringPart> {
+    fn pretty_print(&self) -> Markup {
+        m::operator("\"") + self.iter().map(|p| p.pretty_print()).sum() + m::operator("\"")
+    }
+}
+
 #[derive(Debug, Clone, PartialEq)]
 pub enum Expression {
     Scalar(Span, Number),

+ 1 - 1
numbat/tests/interpreter.rs

@@ -366,5 +366,5 @@ fn test_conditionals() {
 #[test]
 fn test_string_interpolation() {
     expect_output("\"pi = {pi}!\"", "pi = 3.14159!");
-    expect_output("if 4 < 3 then 2 else 1", "1");
+    expect_output("\"1 + 2 = {1 + 2}\"", "1 + 2 = 3");
 }