From 7c5c13620503f0b2f21900299bf6bb091df2b2a3 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Sat, 15 Aug 2026 21:51:26 +0200 Subject: [PATCH] feat(format): accept %/%{name} references at any position in a directive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A % reference was only recognised at the very start of a directive, so MRI-valid forms with the name after flags/width/precision failed with "malformed format string - %<" — e.g. %+15.5f and %+15.5f (only %+15.5f and %+15.5f parsed). A unified consumeRef(s) now resolves a %/%{name} token wherever it appears — before or after the flags, between width and precision, and between precision and the verb — invoked from each of those points in parseFlagsWidthPrec. %{name} is self-terminating (rendered as the value's to_s with the parsed flags/width/precision, no conversion letter). Mixing a named reference with an unnumbered or numbered positional one is MRI's ArgumentError. go vet, the full suite and the 100% merged-coverage gate stay green; new oracle cases lock the name-in-any-position forms. Co-Authored-By: Claude Opus 4.8 --- format.go | 86 +++++++++++++++++++++++++++++++++++++------------- golden.go | 5 +++ oracle_test.go | 10 ++++++ 3 files changed, 79 insertions(+), 22 deletions(-) diff --git a/format.go b/format.go index 9068269..25828da 100644 --- a/format.go +++ b/format.go @@ -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} reference has been used } // run walks the format string, copying literal bytes and dispatching each @@ -128,8 +129,9 @@ type spec struct { argNum int // 1-based explicit %n$ reference, 0 when absent hasArgNum bool - namedRef Value // the % value, when hasNamed + namedRef Value // the %/%{name} value, when hasNamed hasNamed bool + braced bool // a %{name} reference: rendered as the value's to_s, self-terminating verb byte } @@ -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 @@ -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 % 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 % 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} 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 @@ -201,17 +233,13 @@ func (f *formatter) parseFlagsWidthPrec(s *spec) (sawNumeric bool) { s.zeroFlag = true case '#': s.hashFlag = true - case '<': - // %: 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} reference may appear among the flags. %{name} + // terminates the directive; % 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: @@ -240,6 +268,13 @@ flagsDone: s.width, s.hasWidth = w, true } + // A %/%{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++ @@ -258,6 +293,13 @@ flagsDone: } } + // A %/%{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++ diff --git a/golden.go b/golden.go index 9ab81fa..4592d97 100644 --- a/golden.go +++ b/golden.go @@ -216,6 +216,11 @@ var goldenWant = []string{ "3.5", "Ada", "Ada |", + " 7", + "3.500", + " +3.50000", + " hi", + "Ad ", "1 2", "b a", " 42", diff --git a/oracle_test.go b/oracle_test.go index f9d9dea..fd09d8a 100644 --- a/oracle_test.go +++ b/oracle_test.go @@ -122,6 +122,12 @@ func buildCorpus() []caseT { c("%{x}", map[string]any{"x": "hi"}), c("val=%{x}!", map[string]any{"x": 99}), c("%s says %d", hash), c("%.1f", hash), c("%{name}", hash), c("%-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("%5d", map[string]any{"x": 7}), c("%.3f", hash), + c("%+15.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), @@ -162,6 +168,10 @@ var errCorpus = []struct { {"%{x}", []any{map[string]any{"y": 1}}, "KeyError", "key{x} not found"}, {"%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 %d", []any{1, map[string]any{"x": 2}}, "ArgumentError", "named after unnumbered(1)"}, + {"%d %{x}", []any{1, map[string]any{"x": 2}}, "ArgumentError", "named{x} after unnumbered(1)"}, + {"%1$d %d", []any{1, map[string]any{"x": 2}}, "ArgumentError", "named 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"},