DSL format function: support 1-based positional placeholders like {1} (#1650) (#2160)

Plain {} placeholders consume arguments sequentially as before; {N}
refers to the Nth argument (1-based) and may be used to repeat and/or
reorder arguments, e.g. format("{1}/{2}/{1}_{3}.ext", $p1, $p2, $p3).
Mixing is allowed: the {} sequence counter is independent of positional
placeholders (as in Rust format!). Out-of-range indices interpolate the
empty string, consistent with existing too-few-arguments behavior; {0}
is an error value since indices are 1-based.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
John Kerl 2026-07-06 15:15:33 -04:00 committed by GitHub
parent a8ee858e60
commit 1004ba5097
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 213 additions and 59 deletions

View file

@ -381,6 +381,10 @@ func BIF_clean_whitespace(input1 *mlrval.Mlrval) *mlrval.Mlrval {
return mlrval.FromInferredType(mv.String())
}
// Matches either a sequential placeholder "{}" or a positional placeholder
// like "{1}", "{2}", etc.
var _format_placeholder_regexp = regexp.MustCompile(`\{[0-9]*\}`)
func BIF_format(mlrvals []*mlrval.Mlrval) *mlrval.Mlrval {
if len(mlrvals) == 0 {
return mlrval.VOID
@ -390,38 +394,62 @@ func BIF_format(mlrvals []*mlrval.Mlrval) *mlrval.Mlrval {
return mlrval.FromTypeErrorUnary("format", mlrvals[0])
}
pieces := lib.SplitString(formatString, "{}")
// Example: format("{}:{}", 8, 9) gives "8:9".
//
// Placeholders come in two forms:
// * "{}": consumes the next argument in sequence. The sequence counter is
// advanced only by "{}" placeholders, independently of any positional
// placeholders (same as Rust's format!).
// * "{N}" with N >= 1: refers to the Nth argument after the format string
// (1-based). Arguments may be repeated and/or reordered this way:
// format("{1}:{2}:{1}", 8, 9) gives "8:9:8".
//
// Q: What if too few arguments for format, or a positional index exceeds
// the number of arguments?
// A: Interpolate the empty string.
// Q: What if too many arguments for format?
// A: Leave them off.
// Q: What about "{0}"?
// A: It's an error value, since positional placeholders are 1-based.
var buffer bytes.Buffer
// Example: format("{}:{}", 8, 9)
//
// * piece[0] ""
// * piece[1] ":"
// * piece[2] ""
// * mlrval[1] 8
// * mlrval[2] 9
//
// So:
// * Write piece[0]
// * Write mlrvals[1]
// * Write piece[1]
// * Write mlrvals[2]
// * Write piece[2]
n := len(mlrvals) // arguments are mlrvals[1] .. mlrvals[n-1]
position := 1 // implicit counter for "{}" placeholders
remaining := formatString
// Q: What if too few arguments for format?
// A: Leave them off
// Q: What if too many arguments for format?
// A: Leave them off
n := len(mlrvals)
for i, piece := range pieces {
if i > 0 {
if i < n {
buffer.WriteString(mlrvals[i].String())
}
for {
loc := _format_placeholder_regexp.FindStringIndex(remaining)
if loc == nil {
buffer.WriteString(remaining)
break
}
buffer.WriteString(piece)
buffer.WriteString(remaining[:loc[0]])
placeholder := remaining[loc[0]:loc[1]]
remaining = remaining[loc[1]:]
var index int
if placeholder == "{}" {
index = position
position++
} else {
numString := placeholder[1 : len(placeholder)-1]
num, err := strconv.Atoi(numString)
if err != nil || num < 1 {
return mlrval.FromError(
fmt.Errorf(
"format: positional placeholder %s is invalid: indices are 1-based",
placeholder,
),
)
}
index = num
}
if index < n {
buffer.WriteString(mlrvals[index].String())
}
// Else, too few arguments: interpolate the empty string.
}
return mlrval.FromString(buffer.String())

76
pkg/bifs/strings_test.go Normal file
View file

@ -0,0 +1,76 @@
package bifs
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/johnkerl/miller/v6/pkg/mlrval"
)
func TestBIF_format(t *testing.T) {
// No arguments at all
output := BIF_format([]*mlrval.Mlrval{})
assert.True(t, output.IsVoid())
// Non-string format string
output = BIF_format([]*mlrval.Mlrval{mlrval.FromInt(1)})
assert.True(t, output.IsError())
// Sequential placeholders
cases := []struct {
formatString string
args []string
expected string
}{
// Plain "{}" placeholders (pre-existing behavior)
{"", []string{}, ""},
{"abc", []string{}, "abc"},
{"{}", []string{}, ""},
{"{}", []string{"1"}, "1"},
{"{}", []string{"1", "2"}, "1"},
{"{}:{}:{}", []string{"1", "2"}, "1:2:"},
{"{}:{}:{}", []string{"1", "2", "3"}, "1:2:3"},
{"{}:{}:{}", []string{"1", "2", "3", "4"}, "1:2:3"},
{"<{}:{}>", []string{"abc"}, "<abc:>"},
{"<{}:{}>", []string{"abc", "def"}, "<abc:def>"},
// Positional "{N}" placeholders
{"{1}", []string{"a"}, "a"},
{"{1}:{1}", []string{"a"}, "a:a"},
{"{2}:{1}", []string{"a", "b"}, "b:a"},
{"{1}/{2}/{1}_{3}.ext", []string{"a", "b", "c"}, "a/b/a_c.ext"},
// Out-of-range positional index interpolates the empty string,
// consistent with too-few arguments for "{}"
{"{3}", []string{"a"}, ""},
{"<{4}>", []string{"a", "b"}, "<>"},
// Leading zeros are accepted
{"{01}", []string{"a"}, "a"},
// Mixing: "{}" consumes arguments sequentially, independently of
// positional placeholders
{"{2}{}:{1}{}", []string{"3", "4"}, "43:34"},
{"{}{1}{}", []string{"a", "b"}, "aab"},
// Non-placeholder braces are left alone
{"{ }", []string{"a"}, "{ }"},
{"{x}", []string{"a"}, "{x}"},
{"{", []string{"a"}, "{"},
{"}", []string{"a"}, "}"},
}
for _, c := range cases {
mlrvals := []*mlrval.Mlrval{mlrval.FromString(c.formatString)}
for _, arg := range c.args {
mlrvals = append(mlrvals, mlrval.FromString(arg))
}
output := BIF_format(mlrvals)
stringval, ok := output.GetStringValue()
assert.True(t, ok, "format(%q, %v)", c.formatString, c.args)
assert.Equal(t, c.expected, stringval, "format(%q, %v)", c.formatString, c.args)
}
// "{0}" is an error, since positional placeholders are 1-based
output = BIF_format([]*mlrval.Mlrval{mlrval.FromString("{0}"), mlrval.FromString("a")})
assert.True(t, output.IsError())
}

View file

@ -696,11 +696,17 @@ Arrays are new in Miller 6; the substr function is older.`,
name: "format",
class: FUNC_CLASS_STRING,
help: `Using first argument as format string, interpolate remaining arguments in place of
each "{}" in the format string. Too-few arguments are treated as the empty string; too-many arguments are discarded.`,
each "{}" in the format string. Also supports 1-based positional placeholders such as "{1}", which allow
arguments to be reused and/or reordered. Plain "{}" placeholders consume arguments sequentially,
independently of any positional placeholders. Too-few arguments are treated as the empty string, as are
positional placeholders exceeding the number of arguments; too-many arguments are discarded. "{0}" is an
error value, since positional placeholders are 1-based.`,
examples: []string{
`format("{}:{}:{}", 1,2) gives "1:2:".`,
`format("{}:{}:{}", 1,2,3) gives "1:2:3".`,
`format("{}:{}:{}", 1,2,3,4) gives "1:2:3".`,
`format("{}:{}:{}", 1,2) gives "1:2:".`,
`format("{}:{}:{}", 1,2,3) gives "1:2:3".`,
`format("{}:{}:{}", 1,2,3,4) gives "1:2:3".`,
`format("{1}:{2}:{1}", "a","b") gives "a:b:a".`,
`format("{2}{}:{1}{}", 3,4) gives "43:34".`,
},
variadicFunc: bifs.BIF_format,
},