From b45c39f2050cc2f98213ff7c4586aecad2443605 Mon Sep 17 00:00:00 2001 From: Alexander Medvednikov Date: Wed, 11 Mar 2026 16:07:08 +0300 Subject: [PATCH] cgen: dynamic string interpolation format specifiers (fixes #19077) --- doc/docs.md | 6 +- vlib/builtin/string_interpolation.v | 38 +++++++-- vlib/v/ast/ast.v | 26 ++++-- vlib/v/ast/str.v | 17 ++-- vlib/v/checker/str.v | 30 ++++++- vlib/v/comptime/comptime.v | 2 + vlib/v/fmt/tests/string_interpolation_keep.vv | 4 + vlib/v/gen/c/str_intp.v | 40 +++++++++- vlib/v/generics/generics.v | 10 ++- vlib/v/markused/walker.v | 10 +++ vlib/v/parser/parser.v | 79 ++++++++++++++----- .../string_interpolation_test.v | 21 +++++ vlib/v/transformer/transformer.v | 16 +++- 13 files changed, 255 insertions(+), 44 deletions(-) diff --git a/doc/docs.md b/doc/docs.md index c3875252c..228129ae8 100644 --- a/doc/docs.md +++ b/doc/docs.md @@ -722,11 +722,13 @@ To use a format specifier, follow this pattern: > > V does not currently support the use of `'` or `#` as format flags, and V supports but > doesn't need `+` to right-align since that's the default. -- width: may be an integer value describing the minimum width of total field to output. +- width: may be an integer value describing the minimum width of total field to output. For + runtime widths, wrap an `int` expression in parentheses, for example `${name:(width)}`. - precision: an integer value preceded by a `.` will guarantee that many digits after the decimal point without any insignificant trailing zeros. If displaying insignificant zero's is desired, append a `f` specifier to the precision value (see examples below). Applies only to float - variables and is ignored for integer variables. + variables and is ignored for integer variables. Runtime precisions use the same parenthesized + form, for example `${value:(width).(precision)f}`. - type: `f` and `F` specify the input is a float and should be rendered as such, `e` and `E` specify the input is a float and should be rendered as an exponent (partially broken), `g` and `G` specify the input is a float--the renderer will use floating point notation for small values and exponent diff --git a/vlib/builtin/string_interpolation.v b/vlib/builtin/string_interpolation.v index a24b00c16..905288fa5 100644 --- a/vlib/builtin/string_interpolation.v +++ b/vlib/builtin/string_interpolation.v @@ -130,6 +130,9 @@ pub fn get_str_intp_u64_format(fmt_type StrIntpType, in_width int, in_precision return res } +const str_intp_has_dynamic_width = u8(1) +const str_intp_has_dynamic_precision = u8(1 << 1) + // convert from data format to compact u32 pub fn get_str_intp_u32_format(fmt_type StrIntpType, in_width int, in_precision int, in_tail_zeros bool, in_sign bool, in_pad_ch u8, in_base int, in_upper_case bool) u32 { @@ -153,14 +156,16 @@ pub fn get_str_intp_u32_format(fmt_type StrIntpType, in_width int, in_precision fn (data &StrIntpData) process_str_intp_data(mut sb strings.Builder) { x := data.fmt typ := unsafe { StrIntpType(x & 0x1F) } - align := int((x >> 5) & 0x01) + mut align := int((x >> 5) & 0x01) upper_case := ((x >> 7) & 0x01) > 0 sign := int((x >> 8) & 0x01) - precision := int((x >> 9) & 0x7F) + mut precision := int((x >> 9) & 0x7F) tail_zeros := ((x >> 16) & 0x01) > 0 - width := int(i16((x >> 17) & 0x3FF)) + mut width := int(i16((x >> 17) & 0x3FF)) mut base := int(x >> 27) & 0xF fmt_pad_ch := u8((x >> 31) & 0xFF) + has_dynamic_width := (data.dyn_flags & str_intp_has_dynamic_width) != 0 + has_dynamic_precision := (data.dyn_flags & str_intp_has_dynamic_precision) != 0 // no string interpolation is needed, return empty string if typ == .si_no_str { @@ -173,6 +178,18 @@ fn (data &StrIntpData) process_str_intp_data(mut sb strings.Builder) { if base > 0 { base += 2 // we start from 2, 0 == base 10 } + if has_dynamic_width { + width = data.dyn_width + if width < 0 { + width = -width + align = 0 + } else if width > 0 { + align = 1 + } + } + if has_dynamic_precision { + precision = data.dyn_precision + } // mange pad char, for now only 0 allowed mut pad_ch := u8(` `) @@ -182,7 +199,13 @@ fn (data &StrIntpData) process_str_intp_data(mut sb strings.Builder) { } len0_set := if width > 0 { width } else { -1 } - len1_set := if precision == 0x7F { -1 } else { precision } + len1_set := if has_dynamic_precision { + if precision >= 0 { precision } else { -1 } + } else if precision == 0x7F { + -1 + } else { + precision + } sign_set := sign == 1 mut bf := strconv.BF_param{ @@ -672,8 +695,11 @@ pub struct StrIntpData { pub: str string // fmt u64 // expanded version for future use, 64 bit - fmt u32 - d StrIntpMem + fmt u32 + d StrIntpMem + dyn_width int + dyn_precision int + dyn_flags u8 } // str_intp is the main entry point for string interpolation diff --git a/vlib/v/ast/ast.v b/vlib/v/ast/ast.v index fbcdb226c..d40381fa3 100644 --- a/vlib/v/ast/ast.v +++ b/vlib/v/ast/ast.v @@ -270,10 +270,12 @@ pub: fmt_poss []token.Pos pos token.Pos pub mut: - exprs []Expr - expr_types []Type - fmts []u8 - need_fmts []bool // an explicit non-default fmt required, e.g. `x` + exprs []Expr + expr_types []Type + fwidth_exprs []Expr + precision_exprs []Expr + fmts []u8 + need_fmts []bool // an explicit non-default fmt required, e.g. `x` } pub struct CharLiteral { @@ -2693,9 +2695,23 @@ pub fn (node Node) children() []Node { mut children := []Node{} if node is Expr { match node { - StringInterLiteral, Assoc, ArrayInit { + Assoc, ArrayInit { return node.exprs.map(Node(it)) } + StringInterLiteral { + children << node.exprs.map(Node(it)) + for expr in node.fwidth_exprs { + if expr !is EmptyExpr { + children << expr + } + } + for expr in node.precision_exprs { + if expr !is EmptyExpr { + children << expr + } + } + return children + } SelectorExpr, PostfixExpr, UnsafeExpr, AsCast, ParExpr, IfGuardExpr, SizeOf, Likely, TypeOf, ArrayDecompose { children << node.expr diff --git a/vlib/v/ast/str.v b/vlib/v/ast/str.v index 54bf0f364..f1aec62a3 100644 --- a/vlib/v/ast/str.v +++ b/vlib/v/ast/str.v @@ -383,21 +383,28 @@ fn shorten_full_name_based_on_aliases(input string, m2a map[string]string) strin // string if none is needed. For example, '${z:8.3f} ${a:-20} ${a>b+2}' pub fn (lit &StringInterLiteral) get_fspec(i int) string { mut res := []string{} + has_dynamic_width := i < lit.fwidth_exprs.len && lit.fwidth_exprs[i] !is EmptyExpr + has_dynamic_precision := i < lit.precision_exprs.len && lit.precision_exprs[i] !is EmptyExpr needs_fspec := lit.need_fmts[i] || lit.pluss[i] - || (lit.fills[i] && lit.fwidths[i] >= 0) || lit.fwidths[i] != 0 - || lit.precisions[i] != 987698 + || (lit.fills[i] && (lit.fwidths[i] >= 0 || has_dynamic_width)) + || lit.fwidths[i] != 0 || lit.precisions[i] != 987698 || has_dynamic_width + || has_dynamic_precision if needs_fspec { res << ':' if lit.pluss[i] { res << '+' } - if lit.fills[i] && lit.fwidths[i] >= 0 { + if lit.fills[i] && (lit.fwidths[i] >= 0 || has_dynamic_width) { res << '0' } - if lit.fwidths[i] != 0 { + if has_dynamic_width { + res << '(${lit.fwidth_exprs[i].str()})' + } else if lit.fwidths[i] != 0 { res << '${lit.fwidths[i]}' } - if lit.precisions[i] != 987698 { + if has_dynamic_precision { + res << '.(${lit.precision_exprs[i].str()})' + } else if lit.precisions[i] != 987698 { res << '.${lit.precisions[i]}' } if lit.need_fmts[i] { diff --git a/vlib/v/checker/str.v b/vlib/v/checker/str.v index 69a6c5e07..374464c58 100644 --- a/vlib/v/checker/str.v +++ b/vlib/v/checker/str.v @@ -41,6 +41,22 @@ fn (mut c Checker) get_default_fmt(ftyp ast.Type, typ ast.Type) u8 { } } +fn (mut c Checker) check_string_inter_lit_format_expr(mut expr ast.Expr, what string) { + if expr is ast.EmptyExpr { + return + } + expected_type := c.expected_type + c.expected_type = ast.int_type + mut typ := c.expr(mut expr) + c.expected_type = expected_type + typ = c.type_resolver.get_type_or_default(expr, c.check_expr_option_or_result_call(expr, + typ)) + typ = c.table.unalias_num_type(typ) + if typ != ast.int_type && !typ.is_int_literal() { + c.error('${what} expression should return `int`', expr.pos()) + } +} + fn (mut c Checker) string_inter_lit(mut node ast.StringInterLiteral) ast.Type { inside_interface_deref_save := c.inside_interface_deref c.inside_interface_deref = true @@ -63,6 +79,16 @@ fn (mut c Checker) string_inter_lit(mut node ast.StringInterLiteral) ast.Type { c.markused_string_inter_lit(mut node, ftyp) c.fail_if_unreadable(expr, ftyp, 'interpolation object') node.expr_types << ftyp + if i < node.fwidth_exprs.len { + mut width_expr := node.fwidth_exprs[i] + c.check_string_inter_lit_format_expr(mut width_expr, 'width') + node.fwidth_exprs[i] = width_expr + } + if i < node.precision_exprs.len { + mut precision_expr := node.precision_exprs[i] + c.check_string_inter_lit_format_expr(mut precision_expr, 'precision') + node.precision_exprs[i] = precision_expr + } ftyp_sym := c.table.sym(ftyp) typ := if ftyp_sym.kind == .alias && !ftyp_sym.has_method('str') { c.table.unalias_num_type(ftyp) @@ -91,7 +117,9 @@ fn (mut c Checker) string_inter_lit(mut node ast.StringInterLiteral) ast.Type { node.need_fmts[i] = false } } else { // check if given format specifier is valid for type - if node.precisions[i] != 987698 && !typ.is_float() { + has_dynamic_precision := i < node.precision_exprs.len + && node.precision_exprs[i] !is ast.EmptyExpr + if (node.precisions[i] != 987698 || has_dynamic_precision) && !typ.is_float() { c.error('precision specification only valid for float types', node.fmt_poss[i]) } if node.pluss[i] && !typ.is_number() { diff --git a/vlib/v/comptime/comptime.v b/vlib/v/comptime/comptime.v index 73fdde93e..0013d72e5 100644 --- a/vlib/v/comptime/comptime.v +++ b/vlib/v/comptime/comptime.v @@ -454,6 +454,8 @@ pub fn (mut c Comptime) expr(mut node ast.Expr) ast.Expr { } ast.StringInterLiteral { node.exprs = c.exprs(mut node.exprs) + node.fwidth_exprs = c.exprs(mut node.fwidth_exprs) + node.precision_exprs = c.exprs(mut node.precision_exprs) } ast.StructInit { node.update_expr = c.expr(mut node.update_expr) diff --git a/vlib/v/fmt/tests/string_interpolation_keep.vv b/vlib/v/fmt/tests/string_interpolation_keep.vv index 5df184b17..1ac90167f 100644 --- a/vlib/v/fmt/tests/string_interpolation_keep.vv +++ b/vlib/v/fmt/tests/string_interpolation_keep.vv @@ -5,11 +5,15 @@ fn main() { i := 123 a := 'abc' b := 'xyz' + width := 8 + precision := 3 e := 'a: ${a} b: ${b} i: ${i}' d := 'a: ${a:5s} b: ${b:-5s} i: ${i:20d}' + g := '${a:(width)} ${i:0(width)d} ${f64(i):(width).(precision)f}' f := 'a byte string'.bytes() println('a: ${a} ${b} xxx') eprintln('e: ${e}') + eprintln('g: ${g}') _ = ' ${foo.method(bar).str()} ' println('(${some_struct.@type}, ${some_struct.y})') _ := 'CastExpr ${int(d.e).str()}' diff --git a/vlib/v/gen/c/str_intp.v b/vlib/v/gen/c/str_intp.v index c1f7c54f4..ab299fca0 100644 --- a/vlib/v/gen/c/str_intp.v +++ b/vlib/v/gen/c/str_intp.v @@ -238,7 +238,17 @@ fn (mut g Gen) str_format(node ast.StringInterLiteral, i int, fmts []u8) (u64, s if node.fills[i] { pad_ch = 1 } - res := get_str_intp_u32_format(fmt_type, node.fwidths[i], node.precisions[i], remove_tail_zeros, + static_width := if i < node.fwidth_exprs.len && node.fwidth_exprs[i] !is ast.EmptyExpr { + 0 + } else { + node.fwidths[i] + } + static_precision := if i < node.precision_exprs.len && node.precision_exprs[i] !is ast.EmptyExpr { + 987698 + } else { + node.precisions[i] + } + res := get_str_intp_u32_format(fmt_type, static_width, static_precision, remove_tail_zeros, node.pluss[i], u8(pad_ch), base, upper_case) return res, fmt_type.str() @@ -499,7 +509,33 @@ fn (mut g Gen) string_inter_literal(node ast.StringInterLiteral) { g.str_val(node, i, fmts) } - g.write('}}') + g.write('}') + has_dynamic_width := i < node.fwidth_exprs.len && node.fwidth_exprs[i] !is ast.EmptyExpr + has_dynamic_precision := i < node.precision_exprs.len + && node.precision_exprs[i] !is ast.EmptyExpr + if has_dynamic_width || has_dynamic_precision { + g.write(', ') + if has_dynamic_width { + g.expr(node.fwidth_exprs[i]) + } else { + g.write('0') + } + g.write(', ') + if has_dynamic_precision { + g.expr(node.precision_exprs[i]) + } else { + g.write('0') + } + g.write(', ') + g.write(if has_dynamic_width && has_dynamic_precision { + '3' + } else if has_dynamic_width { + '1' + } else { + '2' + }) + } + g.write('}') if i < (node.vals.len - 1) { g.write(', ') } diff --git a/vlib/v/generics/generics.v b/vlib/v/generics/generics.v index 083593f00..5323c1e8d 100644 --- a/vlib/v/generics/generics.v +++ b/vlib/v/generics/generics.v @@ -1157,13 +1157,19 @@ pub fn (mut g Generics) expr(mut node ast.Expr) ast.Expr { ast.StringInterLiteral { if g.cur_concrete_types.len > 0 { mut exprs := node.exprs.clone() + mut fwidth_exprs := node.fwidth_exprs.clone() + mut precision_exprs := node.precision_exprs.clone() return ast.Expr(ast.StringInterLiteral{ ...node - exprs: g.exprs(mut exprs) - expr_types: node.expr_types.map(g.unwrap_generic(it)) + exprs: g.exprs(mut exprs) + expr_types: node.expr_types.map(g.unwrap_generic(it)) + fwidth_exprs: g.exprs(mut fwidth_exprs) + precision_exprs: g.exprs(mut precision_exprs) }) } node.exprs = g.exprs(mut node.exprs) + node.fwidth_exprs = g.exprs(mut node.fwidth_exprs) + node.precision_exprs = g.exprs(mut node.precision_exprs) } ast.StructInit { if g.cur_concrete_types.len > 0 { diff --git a/vlib/v/markused/walker.v b/vlib/v/markused/walker.v index 8d5b6a1d5..3415ff208 100644 --- a/vlib/v/markused/walker.v +++ b/vlib/v/markused/walker.v @@ -828,6 +828,16 @@ fn (mut w Walker) expr(node_ ast.Expr) { ast.StringInterLiteral { w.uses_interp = true w.exprs(node.exprs) + for expr in node.fwidth_exprs { + if expr !is ast.EmptyExpr { + w.expr(expr) + } + } + for expr in node.precision_exprs { + if expr !is ast.EmptyExpr { + w.expr(expr) + } + } } ast.SelectorExpr { w.expr(node.expr) diff --git a/vlib/v/parser/parser.v b/vlib/v/parser/parser.v index 5d0ff5eac..987e24f16 100644 --- a/vlib/v/parser/parser.v +++ b/vlib/v/parser/parser.v @@ -2484,6 +2484,8 @@ fn (mut p Parser) string_expr() ast.Expr { mut has_fmts := []bool{} mut fwidths := []int{} mut precisions := []int{} + mut fwidth_exprs := []ast.Expr{} + mut precision_exprs := []ast.Expr{} mut visible_pluss := []bool{} mut fills := []bool{} mut fmts := []u8{} @@ -2501,8 +2503,10 @@ fn (mut p Parser) string_expr() ast.Expr { mut has_fmt := false mut fwidth := 0 mut fwidthneg := false + mut fwidth_expr := ast.empty_expr // 987698 is a magic default value, unlikely to be present in user input. Note: 0 is valid precision mut precision := 987698 + mut precision_expr := ast.empty_expr mut visible_plus := false mut fill := false mut fmt := `_` // placeholder @@ -2518,18 +2522,44 @@ fn (mut p Parser) string_expr() ast.Expr { } // ${num:2d} if p.tok.kind == .number { - fields := p.tok.lit.split('.') - if fields[0].len > 0 && fields[0][0] == `0` { + if p.peek_tok.kind == .lpar && p.tok.lit == '0' { fill = true + p.next() + fwidth_expr = p.string_inter_format_expr() + } else { + fields := p.tok.lit.split('.') + if fields[0].len > 0 && fields[0][0] == `0` { + fill = true + } + fwidth = fields[0].int() + if fwidthneg { + fwidth = -fwidth + } + if fields.len > 1 { + precision = fields[1].int() + } + p.next() } - fwidth = fields[0].int() - if fwidthneg { - fwidth = -fwidth - } - if fields.len > 1 { - precision = fields[1].int() - } + } else if p.tok.kind == .lpar { + fwidth_expr = p.string_inter_format_expr() + } + if fwidthneg && fwidth_expr !is ast.EmptyExpr { + fwidth_expr = ast.Expr(ast.PrefixExpr{ + op: .minus + pos: fwidth_expr.pos() + right: fwidth_expr + }) + } + if p.tok.kind == .dot { p.next() + if p.tok.kind == .number { + precision = p.tok.lit.int() + p.next() + } else if p.tok.kind == .lpar { + precision_expr = p.string_inter_format_expr() + } else { + return p.error('precision specification should be a number or `(expression)`') + } } if p.tok.kind == .name { if p.tok.lit.len == 1 { @@ -2542,8 +2572,10 @@ fn (mut p Parser) string_expr() ast.Expr { } } fwidths << fwidth + fwidth_exprs << fwidth_expr has_fmts << has_fmt precisions << precision + precision_exprs << precision_expr visible_pluss << visible_plus fmts << fmt fills << fill @@ -2551,22 +2583,31 @@ fn (mut p Parser) string_expr() ast.Expr { } pos = pos.extend(p.prev_tok.pos()) node = ast.StringInterLiteral{ - vals: vals - exprs: exprs - need_fmts: has_fmts - fwidths: fwidths - precisions: precisions - pluss: visible_pluss - fills: fills - fmts: fmts - fmt_poss: fposs - pos: pos + vals: vals + exprs: exprs + need_fmts: has_fmts + fwidths: fwidths + fwidth_exprs: fwidth_exprs + precisions: precisions + precision_exprs: precision_exprs + pluss: visible_pluss + fills: fills + fmts: fmts + fmt_poss: fposs + pos: pos } // need_fmts: prelimery - until checker finds out if really needed p.inside_str_interp = false return node } +fn (mut p Parser) string_inter_format_expr() ast.Expr { + p.check(.lpar) + expr := p.expr(0) + p.check(.rpar) + return expr +} + fn (mut p Parser) parse_number_literal() ast.Expr { mut pos := p.tok.pos() is_neg := p.tok.kind == .minus diff --git a/vlib/v/tests/builtin_strings_and_interpolation/string_interpolation_test.v b/vlib/v/tests/builtin_strings_and_interpolation/string_interpolation_test.v index 9aefd13a5..80487bc48 100644 --- a/vlib/v/tests/builtin_strings_and_interpolation/string_interpolation_test.v +++ b/vlib/v/tests/builtin_strings_and_interpolation/string_interpolation_test.v @@ -31,6 +31,27 @@ fn test_formatted_string_interpolation() { assert si__left == '23 ' } +fn test_dynamic_format_widths() { + width := 10 + left_width := -10 + zero_width := 5 + sign_width := 6 + name := 'abc' + num := 42 + assert '>${name:(width)}<' == '> abc<' + assert '>${name:(left_width)}<' == '>abc <' + assert '>${name:(-width)}<' == '>abc <' + assert '${num:0(zero_width)d}' == '00042' + assert '${num:+(sign_width)d}' == ' +42' +} + +fn test_dynamic_format_precision() { + width := 8 + precision := 3 + value := 12.34567 + assert '>${value:(width).(precision)f}<' == '> 12.346<' +} + fn test_escape_dollar_in_string() { i := 42 assert '(${i})' == '(42)' diff --git a/vlib/v/transformer/transformer.v b/vlib/v/transformer/transformer.v index 252032349..506efef22 100644 --- a/vlib/v/transformer/transformer.v +++ b/vlib/v/transformer/transformer.v @@ -688,6 +688,16 @@ pub fn (mut t Transformer) expr(mut node ast.Expr) ast.Expr { for mut expr in node.exprs { expr = t.expr(mut expr) } + for mut expr in node.fwidth_exprs { + if expr !is ast.EmptyExpr { + expr = t.expr(mut expr) + } + } + for mut expr in node.precision_exprs { + if expr !is ast.EmptyExpr { + expr = t.expr(mut expr) + } + } } ast.StructInit { node.update_expr = t.expr(mut node.update_expr) @@ -1311,10 +1321,12 @@ pub fn (mut t Transformer) simplify_nested_interpolation_in_sb(mut onode ast.Stm // >> sb.write_string('abc ${num}') // >> sb.write_string('abc ${num} ${some_string} ${another_string} end') for idx, w in original.fwidths { - if w != 0 { + if w != 0 + || (idx < original.fwidth_exprs.len && original.fwidth_exprs[idx] !is ast.EmptyExpr) { return false } - if original.precisions[idx] != 987698 { + if original.precisions[idx] != 987698 || (idx < original.precision_exprs.len + && original.precision_exprs[idx] !is ast.EmptyExpr) { return false } if original.need_fmts[idx] { -- 2.39.5