Skip to content

Commit

Permalink
Add support for Rust-style format specifiers in string interpolations
Browse files Browse the repository at this point in the history
Fixes #264.
  • Loading branch information
triallax committed Mar 20, 2024
1 parent 82b7e6f commit b3d995a
Show file tree
Hide file tree
Showing 16 changed files with 271 additions and 45 deletions.
7 changes: 7 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions book/src/example-numbat_syntax.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ 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
print("value of π ≈ {π:.3}") # Format specifiers
assert(1 yard < 1 meter) # Assertion
Expand Down
9 changes: 9 additions & 0 deletions book/src/procedures.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,15 @@ let speed = 25 km/h
print("Speed of the bicycle: {speed} ({speed -> mph})")
```

Format specifiers are also supported in interpolations. For instance:

```nbt
print("{pi:0.2f}") // Prints "3.14"
```

For more information on supported format specifiers, please see
[this page](https://doc.rust-lang.org/std/fmt/#formatting-parameters).

## Testing

The `assert_eq` procedure can be used to test for (approximate) equality of two quantities.
Expand Down
1 change: 1 addition & 0 deletions examples/numbat_syntax.nbt
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ 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
print("value of π ≈ {π:.3}") # Format specifiers

assert(1 yard < 1 meter) # Assertion

Expand Down
1 change: 1 addition & 0 deletions numbat/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ iana-time-zone = "0.1"
termcolor = { version = "1.4.1", optional = true }
html-escape = { version = "0.2.13", optional = true }
rand = "0.8.5"
strfmt = "0.2.4"

[features]
default = ["fetch-exchangerates"]
Expand Down
18 changes: 14 additions & 4 deletions numbat/src/ast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,11 @@ impl PrettyPrint for BinaryOperator {
#[derive(Debug, Clone, PartialEq)]
pub enum StringPart {
Fixed(String),
Interpolation(Span, Box<Expression>),
Interpolation {
span: Span,
expr: Box<Expression>,
format_specifiers: Option<String>,
},
}

#[derive(Debug, Clone, PartialEq)]
Expand Down Expand Up @@ -426,9 +430,15 @@ impl ReplaceSpans for StringPart {
fn replace_spans(&self) -> Self {
match self {
f @ StringPart::Fixed(_) => f.clone(),
StringPart::Interpolation(_, expr) => {
StringPart::Interpolation(Span::dummy(), Box::new(expr.replace_spans()))
}
StringPart::Interpolation {
expr,
format_specifiers,
span: _,
} => StringPart::Interpolation {
span: Span::dummy(),
expr: Box::new(expr.replace_spans()),
format_specifiers: format_specifiers.clone(),
},
}
}
}
Expand Down
10 changes: 9 additions & 1 deletion numbat/src/bytecode_interpreter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -187,8 +187,16 @@ impl BytecodeInterpreter {
let index = self.vm.add_constant(Constant::String(s.clone()));
self.vm.add_op1(Op::LoadConstant, index)
}
StringPart::Interpolation(_, expr) => {
StringPart::Interpolation {
expr,
span: _,
format_specifiers,
} => {
self.compile_expression_with_simplify(expr)?;
let index = self.vm.add_constant(Constant::FormatSpecifiers(
format_specifiers.clone(),
));
self.vm.add_op1(Op::LoadConstant, index)
}
}
}
Expand Down
6 changes: 6 additions & 0 deletions numbat/src/interpreter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ pub enum RuntimeError {
DateTimeOutOfRange,
#[error("Error in datetime format. See https://docs.rs/chrono/latest/chrono/format/strftime/index.html for possible format specifiers.")]
DateFormattingError,

#[error("Invalid format specifiers: {0}")]
InvalidFormatSpecifiers(String),

#[error("Incorrect type for format specifiers: {0}")]
InvalidTypeForFormatSpecifiers(String),
}

#[derive(Debug, PartialEq, Eq)]
Expand Down
78 changes: 57 additions & 21 deletions numbat/src/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1140,10 +1140,9 @@ impl<'a> Parser<'a> {
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 mut parts = Vec::new();

let expr = self.expression()?;
parts.push(StringPart::Interpolation(expr.full_span(), Box::new(expr)));
self.interpolation(&mut parts, &token)?;

let mut span_full_string = token.span;
let mut has_end = false;
Expand All @@ -1154,10 +1153,7 @@ impl<'a> Parser<'a> {
span_full_string = span_full_string.extend(&inner_token.span);
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)));
self.interpolation(&mut parts, &inner_token)?;
}
TokenKind::StringInterpolationEnd => {
parts.push(StringPart::Fixed(strip_first_and_last(&inner_token.lexeme)));
Expand Down Expand Up @@ -1206,6 +1202,24 @@ impl<'a> Parser<'a> {
}
}

fn interpolation(&mut self, parts: &mut Vec<StringPart>, token: &Token) -> Result<()> {
parts.push(StringPart::Fixed(strip_first_and_last(&token.lexeme)));

let expr = self.expression()?;

let format_specifiers = self
.match_exact(TokenKind::StringInterpolationSpecifiers)
.map(|token| token.lexeme.clone());

parts.push(StringPart::Interpolation {
span: expr.full_span(),
expr: Box::new(expr),
format_specifiers,
});

Ok(())
}

/// Returns true iff the upcoming token indicates the beginning of a 'power'
/// expression (which needs to start with a 'primary' expression).
fn next_token_could_start_power_expression(&self) -> bool {
Expand Down Expand Up @@ -2485,7 +2499,11 @@ mod tests {
Span::dummy(),
vec![
StringPart::Fixed("pi = ".into()),
StringPart::Interpolation(Span::dummy(), Box::new(identifier!("pi"))),
StringPart::Interpolation {
span: Span::dummy(),
expr: Box::new(identifier!("pi")),
format_specifiers: None,
},
],
),
);
Expand All @@ -2494,10 +2512,11 @@ mod tests {
&["\"{pi}\""],
Expression::String(
Span::dummy(),
vec![StringPart::Interpolation(
Span::dummy(),
Box::new(identifier!("pi")),
)],
vec![StringPart::Interpolation {
span: Span::dummy(),
expr: Box::new(identifier!("pi")),
format_specifiers: None,
}],
),
);

Expand All @@ -2506,8 +2525,16 @@ mod tests {
Expression::String(
Span::dummy(),
vec![
StringPart::Interpolation(Span::dummy(), Box::new(identifier!("pi"))),
StringPart::Interpolation(Span::dummy(), Box::new(identifier!("e"))),
StringPart::Interpolation {
span: Span::dummy(),
expr: Box::new(identifier!("pi")),
format_specifiers: None,
},
StringPart::Interpolation {
span: Span::dummy(),
expr: Box::new(identifier!("e")),
format_specifiers: None,
},
],
),
);
Expand All @@ -2517,23 +2544,32 @@ mod tests {
Expression::String(
Span::dummy(),
vec![
StringPart::Interpolation(Span::dummy(), Box::new(identifier!("pi"))),
StringPart::Interpolation {
span: Span::dummy(),
expr: Box::new(identifier!("pi")),
format_specifiers: None,
},
StringPart::Fixed(" + ".into()),
StringPart::Interpolation(Span::dummy(), Box::new(identifier!("e"))),
StringPart::Interpolation {
span: Span::dummy(),
expr: Box::new(identifier!("e")),
format_specifiers: None,
},
],
),
);

parse_as_expression(
&["\"1 + 2 = {1 + 2}\""],
&["\"1 + 2 = {1 + 2:0.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))),
),
StringPart::Interpolation {
span: Span::dummy(),
expr: Box::new(binop!(scalar!(1.0), Add, scalar!(2.0))),
format_specifiers: Some(":0.2".to_string()),
},
],
),
);
Expand Down
11 changes: 8 additions & 3 deletions numbat/src/prefix_transformer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,10 +85,15 @@ impl Transformer {
.into_iter()
.map(|p| match p {
f @ StringPart::Fixed(_) => f,
StringPart::Interpolation(span, expr) => StringPart::Interpolation(
StringPart::Interpolation {
span,
Box::new(self.transform_expression(*expr)),
),
expr,
format_specifiers,
} => StringPart::Interpolation {
span,
expr: Box::new(self.transform_expression(*expr)),
format_specifiers,
},
})
.collect(),
),
Expand Down
34 changes: 32 additions & 2 deletions numbat/src/tokenizer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,8 @@ pub enum TokenKind {
StringInterpolationStart,
// A part of a string between two interpolations: `}, and bar = {`
StringInterpolationMiddle,
// Format specifiers for an interpolation, e.g. `:.03f`
StringInterpolationSpecifiers,
// A part of a string which ends an interpolation: `}."`
StringInterpolationEnd,

Expand Down Expand Up @@ -488,8 +490,6 @@ impl Tokenizer {
'⩵' => TokenKind::EqualEqual,
'=' if self.match_char('=') => TokenKind::EqualEqual,
'=' => TokenKind::Equal,
':' if self.match_char(':') => TokenKind::DoubleColon,
':' => TokenKind::Colon,
'@' => TokenKind::At,
'→' | '➞' => TokenKind::Arrow,
'-' if self.match_char('>') => TokenKind::Arrow,
Expand Down Expand Up @@ -548,6 +548,34 @@ impl Tokenizer {
});
}
},
':' if self.interpolation_state.is_inside() => {
while self.peek().map(|c| c != '"' && c != '}').unwrap_or(false) {
self.advance();
}

if self.peek() == Some('"') {
return Err(TokenizerError {
kind: TokenizerErrorKind::UnterminatedStringInterpolation,
span: Span {
start: self.token_start,
end: self.current,
code_source_id: self.code_source_id,
},
});
}
if self.peek() == Some('}') {
TokenKind::StringInterpolationSpecifiers
} else {
return Err(TokenizerError {
kind: TokenizerErrorKind::UnterminatedString,
span: Span {
start: self.token_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();
Expand Down Expand Up @@ -595,6 +623,8 @@ impl Tokenizer {
TokenKind::Identifier
}
}
':' if self.match_char(':') => TokenKind::DoubleColon,
':' => TokenKind::Colon,
c => {
return tokenizer_error(
&self.token_start,
Expand Down
15 changes: 9 additions & 6 deletions numbat/src/typechecker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1022,12 +1022,15 @@ impl TypeChecker {
.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,
Box::new(self.check_expression(expr)?),
))
}
StringPart::Interpolation {
span,
expr,
format_specifiers,
} => Ok(typed_ast::StringPart::Interpolation {
span: *span,
format_specifiers: format_specifiers.clone(),
expr: Box::new(self.check_expression(expr)?),
}),
})
.collect::<Result<_>>()?,
),
Expand Down
22 changes: 19 additions & 3 deletions numbat/src/typed_ast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,15 +129,31 @@ impl Type {
#[derive(Debug, Clone, PartialEq)]
pub enum StringPart {
Fixed(String),
Interpolation(Span, Box<Expression>),
Interpolation {
span: Span,
expr: Box<Expression>,
format_specifiers: Option<String>,
},
}

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("}")
StringPart::Interpolation {
span: _,
expr,
format_specifiers,
} => {
let mut markup = m::operator("{") + expr.pretty_print();

if let Some(format_specifiers) = format_specifiers {
markup += m::text(format_specifiers);
}

markup += m::operator("}");

markup
}
}
}
Expand Down
Loading

0 comments on commit b3d995a

Please sign in to comment.