Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 64 additions & 22 deletions format.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ type formatter struct {
argIdx int // next auto (unnumbered) positional argument
usedNumber bool // a %n$ reference has been used
usedAuto bool // an unnumbered reference has been used
usedNamed bool // a %<name>/%{name} reference has been used
}

// run walks the format string, copying literal bytes and dispatching each
Expand Down Expand Up @@ -128,8 +129,9 @@ type spec struct {
argNum int // 1-based explicit %n$ reference, 0 when absent
hasArgNum bool

namedRef Value // the %<name> value, when hasNamed
namedRef Value // the %<name>/%{name} value, when hasNamed
hasNamed bool
braced bool // a %{name} reference: rendered as the value's to_s, self-terminating

verb byte
}
Expand All @@ -142,13 +144,14 @@ func (f *formatter) directive() {
if f.pos >= len(f.format) {
panic(argumentError("incomplete format specifier; use %% (double %) instead"))
}
// %{name}: insert the named value's to_s, with no conversion.
if f.format[f.pos] == '{' {
f.bracedReference()
return
}
var s spec
sawNumeric := f.parseFlagsWidthPrec(&s)
// %{name}: render the named value's to_s with the parsed flags/width/precision
// and no conversion letter (the character after "}" is a literal).
if s.braced {
f.renderStr(&s, s.namedRef.ToS())
return
}
if s.verb == '%' {
f.b.WriteByte('%')
return
Expand All @@ -167,17 +170,46 @@ func (f *formatter) directive() {
f.render(&s)
}

// bracedReference handles %{name}: it inserts the to_s of the named value with
// no further formatting, raising KeyError when the key is absent.
func (f *formatter) bracedReference() {
end := strings.IndexByte(f.format[f.pos:], '}')
// consumeRef handles a %<name> or %{name} reference token beginning at f.pos,
// resolving its value onto s. A reference may appear at any position within a
// directive (before or after the flags, width, and precision), so the caller
// invokes it at each of those points. A %<name> token only binds the argument
// and returns false so parsing of the remaining flags/width/precision/verb
// continues; a %{name} token is self-terminating (rendered as the value's to_s)
// and returns true. Mixing a named reference with a positional (auto or
// numbered) one is an MRI ArgumentError.
func (f *formatter) consumeRef(s *spec) (braced bool) {
open := f.format[f.pos]
closeCh := byte('>')
if open == '{' {
closeCh = '}'
}
end := strings.IndexByte(f.format[f.pos:], closeCh)
if end < 0 {
panic(argumentError("malformed name - unmatched parenthesis"))
}
name := f.format[f.pos+1 : f.pos+end]
v := f.namedValue(name, '{')
f.b.WriteString(v.ToS())
if f.usedAuto {
panic(argumentError("named" + string(open) + name + string(closeCh) +
" after unnumbered(" + strconv.Itoa(f.argIdx) + ")"))
}
if f.usedNumber {
panic(argumentError("named" + string(open) + name + string(closeCh) + " after numbered"))
}
f.usedNamed = true
s.namedRef = f.namedValue(name, open)
s.hasNamed = true
f.pos += end + 1
if open == '{' {
s.braced = true
return true
}
return false
}

// atRef reports whether the next byte begins a %<name>/%{name} reference token.
func (f *formatter) atRef() bool {
return f.pos < len(f.format) && (f.format[f.pos] == '<' || f.format[f.pos] == '{')
}

// parseFlagsWidthPrec consumes the [n$][flags][width][.prec]verb body of a
Expand All @@ -201,17 +233,13 @@ func (f *formatter) parseFlagsWidthPrec(s *spec) (sawNumeric bool) {
s.zeroFlag = true
case '#':
s.hashFlag = true
case '<':
// %<name>: bind the conversion to the named value. Flags may appear
// on either side of the reference, so this lives in the flag loop.
end := strings.IndexByte(f.format[f.pos:], '>')
if end < 0 {
panic(argumentError("malformed name - unmatched parenthesis"))
case '<', '{':
// A %<name>/%{name} reference may appear among the flags. %{name}
// terminates the directive; %<name> binds the argument and parsing
// continues.
if f.consumeRef(s) {
return sawNumeric
}
name := f.format[f.pos+1 : f.pos+end]
s.namedRef = f.namedValue(name, '<')
s.hasNamed = true
f.pos += end + 1
f.maybeArgNum(s)
continue
default:
Expand Down Expand Up @@ -240,6 +268,13 @@ flagsDone:
s.width, s.hasWidth = w, true
}

// A %<name>/%{name} reference may sit between the width and the precision.
if f.atRef() {
if f.consumeRef(s) {
return sawNumeric
}
}

// Precision.
if f.pos < len(f.format) && f.format[f.pos] == '.' {
f.pos++
Expand All @@ -258,6 +293,13 @@ flagsDone:
}
}

// A %<name>/%{name} reference may sit between the precision and the verb.
if f.atRef() {
if f.consumeRef(s) {
return sawNumeric
}
}

if f.pos < len(f.format) {
s.verb = f.format[f.pos]
f.pos++
Expand Down
5 changes: 5 additions & 0 deletions golden.go

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

10 changes: 10 additions & 0 deletions oracle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,12 @@ func buildCorpus() []caseT {
c("%{x}", map[string]any{"x": "hi"}), c("val=%{x}!", map[string]any{"x": 99}),
c("%<name>s says %<x>d", hash), c("%<f>.1f", hash),
c("%{name}", hash), c("%<name>-10s|", hash),
// A named/braced reference at every position within the directive:
// after the width, after the precision, and (braced) with flags,
// width, and precision.
c("%5<x>d", map[string]any{"x": 7}), c("%.3<f>f", hash),
c("%+15<f>.5f", hash), c("%5{x}", map[string]any{"x": "hi"}),
c("%-8.2{name}", hash),

// Argument indexing and width/precision from args.
c("%d %d", 1, 2), c("%2$s %1$s", "a", "b"), c("%*d", 5, 42),
Expand Down Expand Up @@ -162,6 +168,10 @@ var errCorpus = []struct {
{"%{x}", []any{map[string]any{"y": 1}}, "KeyError", "key{x} not found"},
{"%<x>d", []any{1}, "ArgumentError", "one hash required"},
{"%{x}", []any{1}, "ArgumentError", "one hash required"},
// A named reference cannot be mixed with a positional (auto or numbered) one.
{"%d %<x>d", []any{1, map[string]any{"x": 2}}, "ArgumentError", "named<x> after unnumbered(1)"},
{"%d %{x}", []any{1, map[string]any{"x": 2}}, "ArgumentError", "named{x} after unnumbered(1)"},
{"%1$d %<x>d", []any{1, map[string]any{"x": 2}}, "ArgumentError", "named<x> after numbered"},
{"%d", []any{"notnum"}, "ArgumentError", `invalid value for Integer(): "notnum"`},
{"%f", []any{"notnum"}, "ArgumentError", `invalid value for Float(): "notnum"`},
{"%d", []any{nil}, "TypeError", "can't convert nil into Integer"},
Expand Down