use indexmap::IndexMap;
use rustpython_parser::ast::{self, ConversionFlag, Expr};
use crate::{
error::{EvalError, EvalResult, InterpreterError},
eval::{eval_expr, functions::resolve_proxy},
state::InterpreterState,
tools::Tools,
value::{ExceptionValue, Value, ValueKey},
};
const MAX_FORMAT_WIDTH: i64 = 10_000;
fn spec_usize(n: i64, default: usize) -> usize {
usize::try_from(n).unwrap_or(default)
}
pub async fn eval_joined_str(
state: &mut InterpreterState,
node: &ast::ExprJoinedStr,
tools: &Tools,
) -> EvalResult {
let mut parts = Vec::with_capacity(node.values.len());
for value in &node.values {
match value {
Expr::Constant(c) => {
parts.push(format!("{}", crate::eval::literals::eval_constant(&c.value)));
}
Expr::FormattedValue(fv) => {
let formatted = eval_formatted_value(state, fv, tools).await?;
if let Value::String(s) = formatted {
parts.push(s.into());
} else {
parts.push(format!("{formatted}"));
}
}
_ => {
let result = eval_expr(state, value, tools).await?;
parts.push(format!("{result}"));
}
}
}
Ok(Value::String(parts.join("").into()))
}
pub async fn eval_formatted_value(
state: &mut InterpreterState,
node: &ast::ExprFormattedValue,
tools: &Tools,
) -> EvalResult {
let value = eval_expr(state, &node.value, tools).await?;
let value = resolve_proxy(&value).await?;
use crate::eval::render::{RenderMode, render};
let converted = match node.conversion {
ConversionFlag::Str => {
Value::String(render(state, &value, RenderMode::Display, tools).await?.into())
}
ConversionFlag::Repr => {
Value::String(render(state, &value, RenderMode::Repr, tools).await?.into())
}
ConversionFlag::Ascii => {
Value::String(render(state, &value, RenderMode::Ascii, tools).await?.into())
}
ConversionFlag::None => value,
};
if let Some(ref format_spec) = node.format_spec {
let spec_str = eval_expr(state, format_spec, tools).await?;
let spec: String = match spec_str {
Value::String(s) => s.into(),
other => format!("{other}"),
};
if let Some(rendered) = call_format_slot(state, &converted, &spec, tools).await? {
return Ok(Value::String(rendered.into()));
}
if spec.is_empty() {
Ok(Value::String(render(state, &converted, RenderMode::Display, tools).await?.into()))
} else {
apply_format_spec(&converted, &spec)
}
} else {
if let Some(rendered) = call_format_slot(state, &converted, "", tools).await? {
return Ok(Value::String(rendered.into()));
}
Ok(Value::String(render(state, &converted, RenderMode::Display, tools).await?.into()))
}
}
pub(crate) async fn call_format_slot(
state: &mut InterpreterState,
value: &Value,
spec: &str,
tools: &Tools,
) -> Result<Option<String>, EvalError> {
let Value::Instance(inst) = value else { return Ok(None) };
let Some((_, method)) =
crate::eval::classes::lookup_method_in_mro(state, &inst.class_name, "__format__")
else {
return Ok(None);
};
let spec_arg = Value::String(spec.into());
let call = crate::eval::functions::CallArgs {
positional: std::slice::from_ref(&spec_arg),
keyword: &indexmap::IndexMap::new(),
};
let (returned, _self) =
crate::eval::classes::call_method(state, &method, value.clone(), call, tools).await?;
match returned {
Value::String(s) => Ok(Some(s.into())),
other => Err(crate::error::InterpreterError::TypeError(format!(
"__format__ must return str, not {}",
other.type_name()
))
.into()),
}
}
fn format_value_body(
value: &Value,
type_char: Option<char>,
precision: Option<i64>,
alternate: bool,
) -> Result<String, EvalError> {
let prec = || spec_usize(precision.unwrap_or(6), 6);
match (value, type_char) {
(Value::Int(_) | Value::BigInt(_), _) => {
format_integer(value, type_char, precision, alternate)
}
(Value::Bool(b), None) => Ok(if *b { "True" } else { "False" }.to_string()),
(Value::Bool(b), _) => {
format_integer(&Value::Int(i64::from(*b)), type_char, precision, alternate)
}
(Value::Float(f), Some(c @ ('f' | 'F' | 'e' | 'E' | 'g' | 'G' | 'n' | '%')))
if !f.is_finite() =>
{
let base = if f.is_nan() {
"nan"
} else if *f < 0.0 {
"-inf"
} else {
"inf"
};
let mut out =
if c.is_ascii_uppercase() { base.to_uppercase() } else { base.to_string() };
if c == '%' {
out.push('%');
}
Ok(out)
}
(Value::Float(f), Some('f' | 'F')) => {
let p = prec();
Ok(format!("{f:.p$}"))
}
(Value::Float(f), None) => match precision {
None => Ok(format!("{value}")),
Some(p) => {
let rendered = format_general(*f, spec_usize(p, 6), false, alternate);
if rendered.contains(['.', 'e', 'E']) {
Ok(rendered)
} else {
Ok(format!("{rendered}.0"))
}
}
},
(Value::Float(f), Some('e')) => Ok(format_scientific(*f, prec(), false)),
(Value::Float(f), Some('E')) => Ok(format_scientific(*f, prec(), true)),
(Value::Float(f), Some('g' | 'G' | 'n')) => Ok(format_general(
*f,
precision.map_or(6, |p| spec_usize(p, 6)),
type_char == Some('G'),
alternate,
)),
(Value::Float(f), Some('%')) => Ok(format!("{:.*}%", prec(), f * 100.0)),
(Value::Float(_), Some(c)) => Err(unknown_format_code(c, value)),
(Value::String(s), _) => Ok(precision.map_or_else(
|| s.to_string(),
|p| s.chars().take(spec_usize(p, 0)).collect::<String>(),
)),
(Value::Complex(c), Some('f' | 'F' | 'e' | 'E' | 'g' | 'G' | 'n' | '%')) => {
let re = format_value_body(&Value::Float(c.re), type_char, precision, alternate)?;
let im = format_value_body(&Value::Float(c.im.abs()), type_char, precision, alternate)?;
let sign = if c.im.is_sign_negative() { "-" } else { "+" };
Ok(format!("{re}{sign}{im}j"))
}
(Value::Decimal(d, _), Some('f' | 'F')) => Ok(format_decimal_fixed(d, prec())),
(Value::Decimal(d, _), Some('%')) => {
let scaled = d.as_ref().clone() * bigdecimal::BigDecimal::from(100);
Ok(format!("{}%", format_decimal_fixed(&scaled, prec())))
}
(Value::Decimal(d, _), Some('e' | 'E' | 'g' | 'G' | 'n')) => {
use num_traits::ToPrimitive as _;
let f = d.to_f64().unwrap_or(f64::NAN);
let body = format_value_body(&Value::Float(f), type_char, precision, alternate)?;
Ok(minimize_exponent_digits(&body))
}
(_, None) => Ok(format!("{value}")),
(_, Some(c)) => Err(unknown_format_code(c, value)),
}
}
#[expect(clippy::cast_possible_wrap, reason = "precision is a small spec-bounded value")]
fn format_decimal_fixed(d: &bigdecimal::BigDecimal, precision: usize) -> String {
d.with_scale_round(precision as i64, bigdecimal::RoundingMode::HalfEven).to_plain_string()
}
fn minimize_exponent_digits(s: &str) -> String {
let Some(epos) = s.find(['e', 'E']) else {
return s.to_string();
};
let (mantissa, exp) = s.split_at(epos);
if exp.len() < 3 || !matches!(exp.as_bytes().get(1), Some(b'+' | b'-')) {
return s.to_string();
}
let marker = &exp[..1];
let sign = &exp[1..2];
let trimmed = exp[2..].trim_start_matches('0');
let trimmed = if trimmed.is_empty() { "0" } else { trimmed };
format!("{mantissa}{marker}{sign}{trimmed}")
}
fn unknown_format_code(code: char, value: &Value) -> EvalError {
InterpreterError::ValueError(format!(
"Unknown format code '{code}' for object of type '{}'",
value.type_name()
))
.into()
}
fn format_integer(
value: &Value,
type_char: Option<char>,
precision: Option<i64>,
alternate: bool,
) -> Result<String, EvalError> {
use num_bigint::Sign;
use num_traits::ToPrimitive as _;
if precision.is_some()
&& matches!(type_char, None | Some('d' | 'n' | 'b' | 'o' | 'x' | 'X' | 'c'))
{
return Err(InterpreterError::ValueError(
"Precision not allowed in integer format specifier".into(),
)
.into());
}
let big = crate::value::value_as_bigint(value).ok_or_else(|| {
EvalError::from(InterpreterError::Runtime("expected integer value".into()))
})?;
let prec = spec_usize(precision.unwrap_or(6), 6);
let radix = |kind: char| {
let mag = big.magnitude();
let body = match (kind, alternate) {
('b', false) => format!("{mag:b}"),
('b', true) => format!("{mag:#b}"),
('o', false) => format!("{mag:o}"),
('o', true) => format!("{mag:#o}"),
('x', false) => format!("{mag:x}"),
('x', true) => format!("{mag:#x}"),
('X', false) => format!("{mag:X}"),
_ => format!("0X{mag:X}"),
};
if big.sign() == Sign::Minus { format!("-{body}") } else { body }
};
let as_f64 = || big.to_f64().unwrap_or(f64::INFINITY);
match type_char {
None | Some('d' | 'n') => Ok(big.to_string()),
Some('b') => Ok(radix('b')),
Some('o') => Ok(radix('o')),
Some('x') => Ok(radix('x')),
Some('X') => Ok(radix('X')),
Some('c') => {
let cp = big.to_u32().and_then(char::from_u32).ok_or_else(|| {
EvalError::Exception(ExceptionValue::new(
"OverflowError",
"%c arg not in range(0x110000)",
))
})?;
Ok(cp.to_string())
}
Some('f' | 'F') => Ok(format!("{:.prec$}", as_f64())),
Some('e') => Ok(format_scientific(as_f64(), prec, false)),
Some('E') => Ok(format_scientific(as_f64(), prec, true)),
Some('g' | 'G') => Ok(format_general(as_f64(), prec, type_char == Some('G'), alternate)),
Some('%') => Ok(format!("{:.prec$}%", as_f64() * 100.0)),
Some(c) => Err(unknown_format_code(c, value)),
}
}
pub(crate) fn apply_format_spec(value: &Value, spec: &str) -> EvalResult {
let chars: Vec<char> = spec.chars().collect();
if chars.is_empty() {
return Ok(Value::String(format!("{value}").into()));
}
if let Value::EnumMember { value: inner, kind, .. } = value {
if matches!(
kind,
crate::value::EnumKind::Int
| crate::value::EnumKind::Str
| crate::value::EnumKind::IntFlag
) {
return apply_format_spec(inner, spec);
}
}
match value {
Value::Date(d) => return Ok(Value::String(d.format(spec).to_string().into())),
Value::DateTime { dt, .. } => {
return Ok(Value::String(dt.format(spec).to_string().into()));
}
Value::Time(t) => return Ok(Value::String(t.format(spec).to_string().into())),
_ => {}
}
if let Value::Instance(inst) = value {
return Err(InterpreterError::TypeError(format!(
"unsupported format string passed to {}.__format__",
inst.class_name
))
.into());
}
let (fill, align, rest) = parse_fill_align(&chars);
let (sign, rest) = parse_sign(rest);
let (alternate, rest) =
if !rest.is_empty() && rest[0] == '#' { (true, &rest[1..]) } else { (false, rest) };
let (zero_pad, rest) =
if !rest.is_empty() && rest[0] == '0' { (true, &rest[1..]) } else { (false, rest) };
let (width, rest) = parse_number(rest);
if let Some(w) = width {
if w > MAX_FORMAT_WIDTH {
return Err(crate::error::InterpreterError::LimitExceeded(format!(
"format width {w} exceeds maximum ({MAX_FORMAT_WIDTH})"
))
.into());
}
}
let (grouping, rest) = if !rest.is_empty() && (rest[0] == ',' || rest[0] == '_') {
(Some(rest[0]), &rest[1..])
} else {
(None, rest)
};
let (precision, rest) = if !rest.is_empty() && rest[0] == '.' {
let (p, r) = parse_number(&rest[1..]);
(p, r)
} else {
(None, rest)
};
if rest.len() > 1 {
return Err(InterpreterError::ValueError(format!(
"Invalid format specifier '{spec}' for object of type '{}'",
value.python_type_name()
))
.into());
}
let type_char = if rest.is_empty() { None } else { Some(rest[0]) };
let grp = if matches!(type_char, Some('b' | 'o' | 'x' | 'X')) { 4 } else { 3 };
let raw = format_value_body(value, type_char, precision, alternate)?;
let formatted = match grouping {
Some(sep) => apply_thousands_separator(&raw, sep, grp),
None => raw,
};
let with_sign = match sign {
Some('+')
if matches!(
value,
Value::Int(_)
| Value::BigInt(_)
| Value::Bool(_)
| Value::Float(_)
| Value::Complex(_)
| Value::Decimal(..)
| Value::Fraction(_)
) =>
{
if formatted.starts_with('-') {
formatted
} else {
format!("+{formatted}")
}
}
Some(' ') => {
if formatted.starts_with('-') {
formatted
} else {
format!(" {formatted}")
}
}
_ => formatted,
};
let width = spec_usize(width.unwrap_or(0), 0);
let display_width = with_sign.chars().count();
if display_width >= width {
return Ok(Value::String(with_sign.into()));
}
let fill_char = fill.unwrap_or(if zero_pad { '0' } else { ' ' });
let default_align = if zero_pad {
'='
} else if matches!(
value,
Value::Int(_)
| Value::BigInt(_)
| Value::Float(_)
| Value::Bool(_)
| Value::Complex(_)
| Value::Decimal(..)
| Value::Fraction(_)
) {
'>'
} else {
'<'
};
let padded = match align.unwrap_or(default_align) {
'<' => {
let padding = width - display_width;
format!("{with_sign}{}", fill_char.to_string().repeat(padding))
}
'>' => {
let padding = width - display_width;
format!("{}{with_sign}", fill_char.to_string().repeat(padding))
}
'^' => {
let padding = width - display_width;
let left = padding / 2;
let right = padding - left;
format!(
"{}{with_sign}{}",
fill_char.to_string().repeat(left),
fill_char.to_string().repeat(right)
)
}
'=' => {
let mut head = 0;
if matches!(with_sign.as_bytes().first(), Some(b'-' | b'+' | b' ')) {
head = 1;
}
if let Some(after) = with_sign.get(head..head + 2) {
if matches!(after, "0x" | "0X" | "0o" | "0O" | "0b" | "0B") {
head += 2;
}
}
let (prefix, rest) = with_sign.split_at(head);
match grouping {
Some(sep) if fill_char == '0' => {
let int_end = rest.find(['.', 'e', 'E']).unwrap_or(rest.len());
let (int_grouped, tail) = rest.split_at(int_end);
let bare: String = int_grouped.chars().filter(|c| *c != sep).collect();
let avail = width.saturating_sub(prefix.chars().count() + tail.chars().count());
format!("{prefix}{}{tail}", pad_and_group_zero(&bare, avail, grp, sep))
}
_ => {
let padding = width - display_width;
format!("{prefix}{}{rest}", fill_char.to_string().repeat(padding))
}
}
}
_ => with_sign,
};
Ok(Value::String(padded.into()))
}
fn apply_thousands_separator(raw: &str, sep: char, grp: usize) -> String {
let (sign, rest) = match raw.as_bytes().first() {
Some(b'-' | b'+') => (&raw[..1], &raw[1..]),
_ => ("", raw),
};
let (prefix, rest) = match rest.get(..2) {
Some("0b" | "0B" | "0o" | "0O" | "0x" | "0X") => rest.split_at(2),
_ => ("", rest),
};
let int_end = rest.find(['.', 'e', 'E']).unwrap_or(rest.len());
let (int_part, tail) = rest.split_at(int_end);
let valid_digit = |c: char| if grp == 4 { c.is_ascii_hexdigit() } else { c.is_ascii_digit() };
if int_part.is_empty() || !int_part.chars().all(valid_digit) {
return raw.to_string();
}
let mut grouped = String::with_capacity(int_part.len() + int_part.len() / grp);
let bytes = int_part.as_bytes();
for (i, b) in bytes.iter().enumerate() {
if i > 0 && (bytes.len() - i) % grp == 0 {
grouped.push(sep);
}
grouped.push(*b as char);
}
format!("{sign}{prefix}{grouped}{tail}")
}
fn pad_and_group_zero(digits: &str, avail: usize, grp: usize, sep: char) -> String {
let n = digits.len();
let mut d = n.max(1);
while d + (d - 1) / grp < avail {
d += 1;
}
let mut all = String::with_capacity(d);
for _ in 0..d.saturating_sub(n) {
all.push('0');
}
all.push_str(digits);
let bytes = all.as_bytes();
let mut grouped = String::with_capacity(d + d / grp);
for (i, b) in bytes.iter().enumerate() {
if i > 0 && (bytes.len() - i) % grp == 0 {
grouped.push(sep);
}
grouped.push(*b as char);
}
grouped
}
fn parse_fill_align(chars: &[char]) -> (Option<char>, Option<char>, &[char]) {
let aligns = ['<', '>', '^', '='];
if chars.len() >= 2 && aligns.contains(&chars[1]) {
(Some(chars[0]), Some(chars[1]), &chars[2..])
} else if !chars.is_empty() && aligns.contains(&chars[0]) {
(None, Some(chars[0]), &chars[1..])
} else {
(None, None, chars)
}
}
fn parse_sign(chars: &[char]) -> (Option<char>, &[char]) {
if !chars.is_empty() && (chars[0] == '+' || chars[0] == '-' || chars[0] == ' ') {
(Some(chars[0]), &chars[1..])
} else {
(None, chars)
}
}
fn parse_number(chars: &[char]) -> (Option<i64>, &[char]) {
let mut end = 0;
while end < chars.len() && chars[end].is_ascii_digit() {
end += 1;
}
if end == 0 {
(None, chars)
} else {
let num_str: String = chars[..end].iter().collect();
let num = num_str.parse::<i64>().ok();
(num, &chars[end..])
}
}
fn format_general(val: f64, precision: usize, uppercase: bool, alternate: bool) -> String {
if val.is_nan() {
return if uppercase { "NAN".into() } else { "nan".into() };
}
if val.is_infinite() {
let s = if val < 0.0 { "-inf" } else { "inf" };
return if uppercase { s.to_uppercase() } else { s.into() };
}
let p = precision.max(1);
let mut rendered = if val == 0.0 {
format!("{val:.*}", p - 1)
} else {
let sci = format!("{val:.*e}", p - 1);
let exp: i32 = sci.split('e').nth(1).and_then(|e| e.parse().ok()).unwrap_or(0);
if exp < -4 || exp >= p as i32 {
format_scientific(val, p - 1, uppercase)
} else {
let decimals = usize::try_from(p as i32 - 1 - exp).unwrap_or(0);
format!("{val:.decimals$}")
}
};
if !alternate {
rendered = strip_general_zeros(&rendered);
}
rendered
}
fn strip_general_zeros(s: &str) -> String {
let (mantissa, exp) = match s.find(['e', 'E']) {
Some(i) => (&s[..i], &s[i..]),
None => (s, ""),
};
let trimmed = if mantissa.contains('.') {
mantissa.trim_end_matches('0').trim_end_matches('.')
} else {
mantissa
};
format!("{trimmed}{exp}")
}
fn format_scientific(val: f64, precision: usize, uppercase: bool) -> String {
let formatted = format!("{val:.precision$e}");
let Some(e_idx) = formatted.find('e') else { return formatted };
let mantissa = &formatted[..e_idx];
let exp_part = &formatted[e_idx + 1..];
let (exp_sign, exp_digits) = match exp_part.as_bytes().first() {
Some(b'-') => ('-', &exp_part[1..]),
Some(b'+') => ('+', &exp_part[1..]),
_ => ('+', exp_part),
};
let padded_exp =
if exp_digits.len() < 2 { format!("0{exp_digits}") } else { exp_digits.to_string() };
let e_char = if uppercase { 'E' } else { 'e' };
format!("{mantissa}{e_char}{exp_sign}{padded_exp}")
}
pub(crate) fn template_substitute(
template: &str,
args: &[Value],
kwargs: &IndexMap<String, Value>,
safe: bool,
) -> Result<String, EvalError> {
let positional: Option<IndexMap<ValueKey, Value>> = match args.first() {
Some(Value::Dict(map)) => Some(map.lock().clone()),
_ => None,
};
let lookup = |name: &str| -> Option<Value> {
if let Some(v) = kwargs.get(name) {
return Some(v.clone());
}
positional.as_ref().and_then(|m| m.get(&ValueKey::String(name.into())).cloned())
};
let chars: Vec<char> = template.chars().collect();
let is_ident_start = |c: char| c.is_ascii_alphabetic() || c == '_';
let is_ident_cont = |c: char| c.is_ascii_alphanumeric() || c == '_';
let mut out = String::with_capacity(template.len());
let mut i = 0;
while i < chars.len() {
if chars[i] != '$' {
out.push(chars[i]);
i += 1;
continue;
}
match chars.get(i + 1) {
Some('$') => {
out.push('$');
i += 2;
}
Some('{') => {
let mut j = i + 2;
while j < chars.len() && chars[j] != '}' {
j += 1;
}
let name: String = chars[i + 2..j.min(chars.len())].iter().collect();
let valid = j < chars.len()
&& !name.is_empty()
&& name.chars().next().is_some_and(is_ident_start)
&& name.chars().all(is_ident_cont);
if valid {
match lookup(&name) {
Some(v) => out.push_str(&format!("{v}")),
None if safe => out.extend(&chars[i..=j]),
None => {
return Err(EvalError::Exception(ExceptionValue::new(
"KeyError",
format!("'{name}'"),
)));
}
}
i = j + 1;
} else if safe {
out.push('$');
i += 1;
} else {
return Err(InterpreterError::ValueError(
"Invalid placeholder in string".into(),
)
.into());
}
}
Some(&c) if is_ident_start(c) => {
let mut j = i + 1;
while j < chars.len() && is_ident_cont(chars[j]) {
j += 1;
}
let name: String = chars[i + 1..j].iter().collect();
match lookup(&name) {
Some(v) => out.push_str(&format!("{v}")),
None if safe => out.extend(&chars[i..j]),
None => {
return Err(EvalError::Exception(ExceptionValue::new(
"KeyError",
format!("'{name}'"),
)));
}
}
i = j;
}
_ if safe => {
out.push('$');
i += 1;
}
_ => {
return Err(
InterpreterError::ValueError("Invalid placeholder in string".into()).into()
);
}
}
}
Ok(out)
}
pub async fn str_format(
state: &mut InterpreterState,
template: &str,
args: &[Value],
kwargs: &IndexMap<String, Value>,
tools: &Tools,
) -> EvalResult {
let chars: Vec<char> = template.chars().collect();
let mut out = String::new();
let mut auto_index: usize = 0;
let mut i = 0;
while i < chars.len() {
match chars.get(i) {
Some('{') if chars.get(i + 1) == Some(&'{') => {
out.push('{');
i += 2;
}
Some('}') if chars.get(i + 1) == Some(&'}') => {
out.push('}');
i += 2;
}
Some('{') => {
let mut j = i + 1;
let mut depth = 1usize;
while j < chars.len() {
match chars.get(j) {
Some('{') => depth += 1,
Some('}') => {
depth -= 1;
if depth == 0 {
break;
}
}
_ => {}
}
j += 1;
}
if j >= chars.len() {
return Err(InterpreterError::ValueError(
"Single '{' encountered in format string".into(),
)
.into());
}
let field: String = chars[i + 1..j].iter().collect();
out.push_str(&value_text(
render_format_field(state, &field, args, kwargs, &mut auto_index, tools)
.await?,
));
i = j + 1;
}
Some('}') => {
return Err(InterpreterError::ValueError(
"Single '}' encountered in format string".into(),
)
.into());
}
Some(other) => {
out.push(*other);
i += 1;
}
None => break,
}
}
Ok(Value::String(out.into()))
}
fn value_text(value: Value) -> String {
match value {
Value::String(s) => s.into(),
other => format!("{other}"),
}
}
fn render_format_field<'a>(
state: &'a mut InterpreterState,
field: &'a str,
args: &'a [Value],
kwargs: &'a IndexMap<String, Value>,
auto_index: &'a mut usize,
tools: &'a Tools,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = EvalResult> + Send + 'a>> {
Box::pin(async move {
let (head, spec) = match field.split_once(':') {
Some((h, s)) => (h, Some(s)),
None => (field, None),
};
let (name_part, conversion) = match head.rsplit_once('!') {
Some((name, conv)) if conv.chars().count() == 1 => (name, conv.chars().next()),
_ => (head, None),
};
use crate::eval::render::{RenderMode, render};
let value = resolve_format_arg(name_part, args, kwargs, auto_index)?;
let converted = match conversion {
None => value,
Some('s') => {
Value::String(render(state, &value, RenderMode::Display, tools).await?.into())
}
Some('r') => {
Value::String(render(state, &value, RenderMode::Repr, tools).await?.into())
}
Some('a') => {
Value::String(render(state, &value, RenderMode::Ascii, tools).await?.into())
}
Some(other) => {
return Err(InterpreterError::ValueError(format!(
"Unknown conversion specifier {other}"
))
.into());
}
};
let resolved_spec: Option<String> = match spec {
None => None,
Some(s) if s.contains('{') => {
Some(resolve_nested_spec(state, s, args, kwargs, auto_index, tools).await?)
}
Some(s) => Some(s.to_string()),
};
let spec_for_slot = resolved_spec.as_deref().unwrap_or("");
if let Some(rendered) = call_format_slot(state, &converted, spec_for_slot, tools).await? {
return Ok(Value::String(rendered.into()));
}
match resolved_spec {
None => Ok(Value::String(
render(state, &converted, RenderMode::Display, tools).await?.into(),
)),
Some(s) if s.is_empty() => Ok(Value::String(
render(state, &converted, RenderMode::Display, tools).await?.into(),
)),
Some(s) => apply_format_spec(&converted, &s),
}
})
}
async fn resolve_nested_spec(
state: &mut InterpreterState,
spec: &str,
args: &[Value],
kwargs: &IndexMap<String, Value>,
auto_index: &mut usize,
tools: &Tools,
) -> Result<String, EvalError> {
let chars: Vec<char> = spec.chars().collect();
let mut out = String::new();
let mut i = 0;
while i < chars.len() {
match chars.get(i) {
Some('{') => {
let mut j = i + 1;
while j < chars.len() && chars.get(j) != Some(&'}') {
j += 1;
}
if j >= chars.len() {
return Err(InterpreterError::ValueError(
"unmatched '{' in format spec".into(),
)
.into());
}
let inner: String = chars[i + 1..j].iter().collect();
out.push_str(&value_text(
render_format_field(state, &inner, args, kwargs, auto_index, tools).await?,
));
i = j + 1;
}
Some(other) => {
out.push(*other);
i += 1;
}
None => break,
}
}
Ok(out)
}
fn resolve_format_arg(
name_part: &str,
args: &[Value],
kwargs: &IndexMap<String, Value>,
auto_index: &mut usize,
) -> EvalResult {
let base_end = name_part.find(['.', '[']).unwrap_or(name_part.len());
let (base, mut rest) = name_part.split_at(base_end);
let mut current = if base.is_empty() {
let idx = *auto_index;
*auto_index += 1;
args.get(idx).cloned().ok_or_else(|| {
EvalError_index_error(format!(
"Replacement index {idx} out of range for positional args tuple"
))
})?
} else if base.chars().all(|c| c.is_ascii_digit()) {
let idx: usize = base
.parse()
.map_err(|_| EvalError_value_error(format!("invalid positional field '{base}'")))?;
args.get(idx).cloned().ok_or_else(|| {
EvalError_index_error(format!(
"Replacement index {idx} out of range for positional args tuple"
))
})?
} else {
kwargs.get(base).cloned().ok_or_else(|| {
EvalError::Exception(ExceptionValue::new("KeyError", format!("'{base}'")))
})?
};
while !rest.is_empty() {
if let Some(after_dot) = rest.strip_prefix('.') {
let end = after_dot.find(['.', '[']).unwrap_or(after_dot.len());
let (attr, tail) = after_dot.split_at(end);
current = format_get_attr(¤t, attr)?;
rest = tail;
} else if let Some(after_brk) = rest.strip_prefix('[') {
let Some(close) = after_brk.find(']') else {
return Err(EvalError_value_error("expected ']' in format field".into()));
};
let key = &after_brk[..close];
current = format_get_item(¤t, key)?;
rest = &after_brk[close + 1..];
} else {
return Err(EvalError_value_error(format!("invalid format field accessor '{rest}'")));
}
}
Ok(current)
}
fn format_get_attr(value: &Value, attr: &str) -> EvalResult {
if let Some(resolved) = crate::eval::names::resolve_object_attr(value, attr) {
return Ok(resolved);
}
crate::security::validator::validate_attribute(attr)?;
if let Value::Dict(map) = value {
return map
.lock()
.get(&ValueKey::String(attr.into()))
.cloned()
.ok_or_else(|| EvalError_value_error(format!("dict has no key '{attr}'")));
}
if let Value::Instance(inst) = value {
if let Some(v) = inst.fields.lock().get(attr) {
return Ok(v.clone());
}
}
if let Some(resolved) = crate::types::dispatch_getattr_opt(value, attr)? {
return Ok(resolved);
}
Err(InterpreterError::AttributeError(format!(
"'{}' object has no attribute '{attr}'",
value.type_name()
))
.into())
}
fn format_get_item(value: &Value, key: &str) -> EvalResult {
if key.chars().all(|c| c.is_ascii_digit()) && !key.is_empty() {
let idx: usize =
key.parse().map_err(|_| EvalError_value_error(format!("bad index '{key}'")))?;
return match value {
Value::List(items) => items
.lock()
.get(idx)
.cloned()
.ok_or_else(|| EvalError_value_error("format index out of range".into())),
Value::Tuple(items) => items
.get(idx)
.cloned()
.ok_or_else(|| EvalError_value_error("format index out of range".into())),
_ => match value {
Value::Dict(map) => map
.lock()
.get(&ValueKey::Int(idx_to_i64(idx)?))
.cloned()
.ok_or_else(|| EvalError_value_error(format!("dict has no key {idx}"))),
other => Err(InterpreterError::TypeError(format!(
"'{}' object is not subscriptable",
other.type_name()
))
.into()),
},
};
}
match value {
Value::Dict(map) => map
.lock()
.get(&ValueKey::String(key.into()))
.cloned()
.ok_or_else(|| EvalError_value_error(format!("dict has no key '{key}'"))),
other => Err(InterpreterError::TypeError(format!(
"'{}' object is not subscriptable",
other.type_name()
))
.into()),
}
}
fn idx_to_i64(idx: usize) -> Result<i64, EvalError> {
i64::try_from(idx).map_err(|_| EvalError_value_error("format index overflows i64".into()))
}
#[expect(
non_snake_case,
reason = "reads as a ValueError constructor at the dozen format-field call sites; \
a PascalCase name would imply a type and a snake helper named `value_error` \
collides with the local `parse_sign`-style verbs"
)]
fn EvalError_value_error(message: String) -> EvalError {
InterpreterError::ValueError(message).into()
}
#[allow(non_snake_case, reason = "matches the sibling EvalError_value_error constructor name")]
fn EvalError_index_error(message: String) -> EvalError {
EvalError::Exception(ExceptionValue::new("IndexError", message))
}
pub fn bytes_percent_format(template: &[u8], arg: &Value) -> EvalResult {
let tmpl = decode_and_normalize_bytes_template(template);
let converted = latin1_bytes_args(arg);
let Value::String(result) = str_percent_format(&tmpl, &converted)? else {
return Err(InterpreterError::Runtime("bytes format produced non-string".into()).into());
};
Ok(Value::Bytes(result.chars().map(|c| c as u8).collect()))
}
pub async fn bytes_percent_format_async(
state: &mut InterpreterState,
template: &[u8],
arg: &Value,
tools: &Tools,
) -> EvalResult {
let tmpl = decode_and_normalize_bytes_template(template);
let converted = latin1_bytes_args(arg);
let Value::String(result) = str_percent_format_async(state, &tmpl, &converted, tools).await?
else {
return Err(InterpreterError::Runtime("bytes format produced non-string".into()).into());
};
Ok(Value::Bytes(result.chars().map(|c| c as u8).collect()))
}
fn decode_and_normalize_bytes_template(template: &[u8]) -> String {
let mut out = String::with_capacity(template.len());
let mut i = 0;
while i < template.len() {
let c = template[i] as char;
i += 1;
out.push(c);
if c != '%' {
continue;
}
if template.get(i) == Some(&b'%') {
out.push('%');
i += 1;
continue;
}
if template.get(i) == Some(&b'(') {
while i < template.len() {
let ch = template[i] as char;
out.push(ch);
i += 1;
if ch == ')' {
break;
}
}
}
while i < template.len() {
let ch = template[i] as char;
i += 1;
if ch.is_ascii_alphabetic() && !matches!(ch, 'l' | 'h' | 'L') {
out.push(if ch == 'b' { 's' } else { ch });
break;
}
out.push(ch);
}
}
out
}
fn latin1_bytes_args(arg: &Value) -> Value {
fn conv(v: &Value) -> Value {
match v {
Value::Bytes(b) => {
Value::String(b.iter().map(|&x| x as char).collect::<String>().into())
}
Value::ByteArray(b) => {
Value::String(b.lock().iter().map(|&x| x as char).collect::<String>().into())
}
other => other.clone(),
}
}
match arg {
Value::Tuple(items) => Value::Tuple(items.iter().map(conv).collect()),
Value::Dict(_) => arg.clone(),
other => conv(other),
}
}
fn percent_star_arg(positional: &[Value], next_arg: &mut usize) -> Result<i64, EvalError> {
let v = positional.get(*next_arg).ok_or_else(|| {
EvalError::from(InterpreterError::TypeError(
"not enough arguments for format string".into(),
))
})?;
*next_arg += 1;
match v {
Value::Int(n) => Ok(*n),
Value::Bool(b) => Ok(i64::from(*b)),
other => {
Err(InterpreterError::TypeError(format!("* wants int, not {}", other.type_name()))
.into())
}
}
}
enum PercentPiece {
Literal(String),
Conv { spec: PercentSpec, value: Value },
}
fn parse_percent_pieces(template: &str, arg: &Value) -> Result<Vec<PercentPiece>, EvalError> {
let chars: Vec<char> = template.chars().collect();
let positional: Vec<Value> = match arg {
Value::Tuple(items) => items.clone(),
Value::Dict(_) => Vec::new(),
other => vec![other.clone()],
};
let mapping = arg.as_dict().map(|m| m.lock().clone());
let mut pieces: Vec<PercentPiece> = Vec::new();
let mut lit = String::new();
let mut next_arg = 0usize;
let mut i = 0;
while i < chars.len() {
if chars[i] != '%' {
lit.push(chars[i]);
i += 1;
continue;
}
i += 1; if chars.get(i) == Some(&'%') {
lit.push('%');
i += 1;
continue;
}
let mut mapping_key: Option<String> = None;
if chars.get(i) == Some(&'(') {
let mut j = i + 1;
let mut key = String::new();
while j < chars.len() && chars[j] != ')' {
key.push(chars[j]);
j += 1;
}
if j >= chars.len() {
return Err(InterpreterError::ValueError("incomplete format key".into()).into());
}
mapping_key = Some(key);
i = j + 1;
}
let mut flag_minus = false;
let mut flag_plus = false;
let mut flag_space = false;
let mut flag_zero = false;
let mut flag_alt = false;
while let Some(&c) = chars.get(i) {
match c {
'-' => flag_minus = true,
'+' => flag_plus = true,
' ' => flag_space = true,
'0' => flag_zero = true,
'#' => flag_alt = true,
_ => break,
}
i += 1;
}
let mut width = String::new();
if chars.get(i) == Some(&'*') {
i += 1;
let w = percent_star_arg(&positional, &mut next_arg)?;
if w < 0 {
flag_minus = true;
width = w.checked_neg().unwrap_or(i64::MAX).to_string();
} else {
width = w.to_string();
}
} else {
while let Some(&c) = chars.get(i) {
if c.is_ascii_digit() {
width.push(c);
i += 1;
} else {
break;
}
}
}
let precision: Option<String> = if chars.get(i) == Some(&'.') {
i += 1;
if chars.get(i) == Some(&'*') {
i += 1;
let p = percent_star_arg(&positional, &mut next_arg)?;
if p < 0 { None } else { Some(p.to_string()) }
} else {
let mut p = String::new();
while let Some(&c) = chars.get(i) {
if c.is_ascii_digit() {
p.push(c);
i += 1;
} else {
break;
}
}
Some(p)
}
} else {
None
};
while matches!(chars.get(i), Some('l' | 'h' | 'L')) {
i += 1;
}
let Some(&conv) = chars.get(i) else {
return Err(InterpreterError::ValueError("incomplete format".into()).into());
};
i += 1;
let value = if let Some(ref key) = mapping_key {
let map = mapping.as_ref().ok_or_else(|| {
EvalError::from(InterpreterError::TypeError("format requires a mapping".into()))
})?;
map.get(&ValueKey::String(key.as_str().into())).cloned().ok_or_else(|| {
EvalError::Exception(ExceptionValue::new("KeyError", format!("'{key}'")))
})?
} else {
let v = positional.get(next_arg).cloned().ok_or_else(|| {
EvalError::from(InterpreterError::TypeError(
"not enough arguments for format string".into(),
))
})?;
next_arg += 1;
v
};
let spec = PercentSpec {
minus: flag_minus,
sign: if flag_plus {
Some('+')
} else if flag_space {
Some(' ')
} else {
None
},
zero: flag_zero,
alt: flag_alt,
width: parse_opt_i64(&width),
precision: precision.as_ref().map(|p| parse_opt_i64(p).unwrap_or(0)),
conv,
};
if !lit.is_empty() {
pieces.push(PercentPiece::Literal(std::mem::take(&mut lit)));
}
pieces.push(PercentPiece::Conv { spec, value });
}
if !lit.is_empty() {
pieces.push(PercentPiece::Literal(lit));
}
if mapping_key_unused(mapping.as_ref(), next_arg, positional.len()) {
return Err(InterpreterError::TypeError(
"not all arguments converted during string formatting".into(),
)
.into());
}
Ok(pieces)
}
pub fn str_percent_format(template: &str, arg: &Value) -> EvalResult {
let pieces = parse_percent_pieces(template, arg)?;
let mut out = String::new();
for piece in &pieces {
match piece {
PercentPiece::Literal(s) => out.push_str(s),
PercentPiece::Conv { spec, value } => {
out.push_str(&value_text(format_percent_conversion(value, spec)?));
}
}
}
Ok(Value::String(out.into()))
}
pub async fn str_percent_format_async(
state: &mut InterpreterState,
template: &str,
arg: &Value,
tools: &Tools,
) -> EvalResult {
let pieces = parse_percent_pieces(template, arg)?;
let mut out = String::new();
for piece in &pieces {
match piece {
PercentPiece::Literal(s) => out.push_str(s),
PercentPiece::Conv { spec, value } => {
let coerced = coerce_percent_operand(state, value, spec.conv, tools).await?;
let eff = if matches!(value, Value::Instance(_))
&& matches!(spec.conv, 's' | 'r' | 'a')
{
PercentSpec { conv: 's', ..*spec }
} else {
*spec
};
out.push_str(&value_text(format_percent_conversion(&coerced, &eff)?));
}
}
}
Ok(Value::String(out.into()))
}
async fn coerce_percent_operand(
state: &mut InterpreterState,
value: &Value,
conv: char,
tools: &Tools,
) -> Result<Value, EvalError> {
if !matches!(value, Value::Instance(_)) {
return Ok(value.clone());
}
match conv {
's' => Ok(Value::String(
crate::eval::render::render(
state,
value,
crate::eval::render::RenderMode::Display,
tools,
)
.await?
.into(),
)),
'r' | 'a' => {
let mode = if conv == 'a' {
crate::eval::render::RenderMode::Ascii
} else {
crate::eval::render::RenderMode::Repr
};
Ok(Value::String(crate::eval::render::render(state, value, mode, tools).await?.into()))
}
'd' | 'i' | 'u' => {
coerce_via_int_dunders(state, value, &["__index__", "__int__"], tools).await
}
'x' | 'X' | 'o' | 'c' => coerce_via_int_dunders(state, value, &["__index__"], tools).await,
'e' | 'E' | 'f' | 'F' | 'g' | 'G' => {
for slot in ["__float__", "__index__"] {
if let Some(res) =
crate::eval::op::instance_unary_dunder(state, value, slot, tools).await
{
let r = res?;
return match r {
Value::Float(_) | Value::Int(_) | Value::BigInt(_) | Value::Bool(_) => {
Ok(r)
}
other => Err(InterpreterError::TypeError(format!(
"{slot} returned non-float (type {})",
other.type_name()
))
.into()),
};
}
}
Ok(value.clone())
}
_ => Ok(value.clone()),
}
}
async fn coerce_via_int_dunders(
state: &mut InterpreterState,
value: &Value,
slots: &[&str],
tools: &Tools,
) -> Result<Value, EvalError> {
for slot in slots {
if let Some(res) = crate::eval::op::instance_unary_dunder(state, value, slot, tools).await {
let r = res?;
return match r {
Value::Int(_) | Value::BigInt(_) | Value::Bool(_) => Ok(r),
other => Err(InterpreterError::TypeError(format!(
"{slot} returned non-int (type {})",
other.type_name()
))
.into()),
};
}
}
Ok(value.clone())
}
#[derive(Clone, Copy)]
struct PercentSpec {
minus: bool,
sign: Option<char>,
zero: bool,
alt: bool,
width: Option<i64>,
precision: Option<i64>,
conv: char,
}
fn parse_opt_i64(s: &str) -> Option<i64> {
if s.is_empty() { None } else { s.parse::<i64>().ok() }
}
const fn mapping_key_unused(
mapping: Option<&IndexMap<ValueKey, Value>>,
consumed: usize,
total: usize,
) -> bool {
mapping.is_none() && consumed < total
}
fn format_percent_conversion(value: &Value, spec: &PercentSpec) -> EvalResult {
let coerced = match spec.conv {
'c' => {
use num_traits::ToPrimitive as _;
let ch = match value {
Value::String(s) if s.chars().count() == 1 => s.to_string(),
Value::Int(_) | Value::BigInt(_) | Value::Bool(_) => {
crate::value::value_as_bigint(value)
.and_then(|b| b.to_u32())
.and_then(char::from_u32)
.map(|c| c.to_string())
.ok_or_else(|| {
EvalError::Exception(ExceptionValue::new(
"OverflowError",
"%c arg not in range(0x110000)",
))
})?
}
_ => {
return Err(
InterpreterError::TypeError("%c requires int or char".into()).into()
);
}
};
return apply_format_spec(&Value::String(ch.into()), &build_brace_spec(spec, 's'));
}
'd' | 'i' | 'u' | 'o' | 'x' | 'X' => match value {
Value::Int(_) | Value::BigInt(_) => value.clone(),
Value::Bool(b) => Value::Int(i64::from(*b)),
Value::Float(f) => Value::Int(percent_trunc(*f)),
_ => {
let requirement = match spec.conv {
'o' | 'x' | 'X' => "an integer",
_ => "a real number",
};
return Err(InterpreterError::TypeError(format!(
"%{} format: {requirement} is required, not {}",
spec.conv,
value.type_name()
))
.into());
}
},
'e' | 'E' | 'f' | 'F' | 'g' | 'G' => Value::Float(value.as_float().ok_or_else(|| {
EvalError::from(InterpreterError::TypeError(format!(
"must be real number, not {}",
value.type_name()
)))
})?),
's' => Value::String(format!("{value}").into()),
'r' => Value::String(value.repr().into()),
'a' => Value::String(crate::eval::render::ascii_escape(&value.repr()).into()),
_ => {
return Err(InterpreterError::ValueError(format!(
"unsupported format character '{}'",
spec.conv
))
.into());
}
};
let type_char = match spec.conv {
'i' | 'u' => 'd',
'a' => 's',
other => other,
};
apply_format_spec(&coerced, &build_brace_spec(spec, type_char))
}
#[expect(
clippy::cast_possible_truncation,
reason = "Python's %d/%x truncate a float operand toward zero before formatting; \
out-of-range values saturate, matching the lossy C printf semantics"
)]
fn percent_trunc(f: f64) -> i64 {
f.trunc() as i64
}
fn build_brace_spec(spec: &PercentSpec, type_char: char) -> String {
let mut s = String::new();
if spec.minus {
s.push('<');
} else if !spec.zero {
s.push('>');
}
if let Some(sign) = spec.sign {
s.push(sign);
}
if spec.alt {
s.push('#');
}
if spec.zero && !spec.minus {
s.push('0');
}
if let Some(w) = spec.width {
s.push_str(&w.to_string());
}
if let Some(p) = spec.precision {
s.push('.');
s.push_str(&p.to_string());
}
s.push(type_char);
s
}