From b31e17bccb154f087ce2fa247c2bf4048892e250 Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Thu, 19 Mar 2026 23:54:18 +0100 Subject: [PATCH 1/4] cli/command/formatter: optimize ContainerContext.Names Optimize formatting of container name(s); - Inline `StripNamePrefix` in the loop, so that we don't have to construct a new slice with names (in most cases only to pick the first one). - Don't use `strings.Split`, as it allocates a new slice and we only used it to check if the container-name was a legacy-link (contained slashes). - Use a string-builder to concatenate names when not truncating instead of using an intermediate slice (and `strings.Join`). Signed-off-by: Sebastiaan van Stijn --- cli/command/formatter/container.go | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/cli/command/formatter/container.go b/cli/command/formatter/container.go index fafa6a56e0d9..3816455ab688 100644 --- a/cli/command/formatter/container.go +++ b/cli/command/formatter/container.go @@ -141,18 +141,28 @@ func (c *ContainerContext) ID() string { // Names returns a comma-separated string of the container's names, with their // slash (/) prefix stripped. Additional names for the container (related to the -// legacy `--link` feature) are omitted. +// legacy `--link` feature) are omitted when formatting "truncated". func (c *ContainerContext) Names() string { - names := StripNamePrefix(c.c.Names) - if c.trunc { - for _, name := range names { - if len(strings.Split(name, "/")) == 1 { - names = []string{name} - break + var b strings.Builder + for i, n := range c.c.Names { + name := strings.TrimPrefix(n, "/") + if c.trunc { + // When printing truncated, we only print a single name. + // + // Pick the first name that's not a legacy link (does not have + // slashes inside the name itself (e.g., "/other-container/link")). + // Normally this would be the first name found. + if strings.IndexByte(name, '/') == -1 { + return name } + continue + } + if i > 0 { + b.WriteByte(',') } + b.WriteString(name) } - return strings.Join(names, ",") + return b.String() } // StripNamePrefix removes prefix from string, typically container names as returned by `ContainersList` API From c368143240313be06701db077fd585641e94b473 Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Fri, 20 Mar 2026 11:11:58 +0100 Subject: [PATCH 2/4] cli/command/formatter: Context.postFormat: remove redundant buffer A tabwriter is backed by a buffer already, because it needs to re-flow columns based on content written to it. This buffer was added in [moby@ea61dac9e6] as part of a new feature to allow for custom delimiters; neither the patch, nor code-review on the PR mention the extra buffer, so it likely was just overlooked. This patch; - removes the redundant buffer - adds an early return for cases where no tabwriter is used. [moby@ea61dac9e6]: https://github.com/moby/moby/commit/ea61dac9e6d04879445f9c34729055ac1bb15050 Signed-off-by: Sebastiaan van Stijn --- cli/command/formatter/formatter.go | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/cli/command/formatter/formatter.go b/cli/command/formatter/formatter.go index 88905cd1be5c..36872b148370 100644 --- a/cli/command/formatter/formatter.go +++ b/cli/command/formatter/formatter.go @@ -82,20 +82,21 @@ func (c *Context) parseFormat() (*template.Template, error) { } func (c *Context) postFormat(tmpl *template.Template, subContext SubContext) { - if c.Output == nil { - c.Output = io.Discard + out := c.Output + if out == nil { + out = io.Discard } - if c.Format.IsTable() { - t := tabwriter.NewWriter(c.Output, 10, 1, 3, ' ', 0) - buffer := bytes.NewBufferString("") - tmpl.Funcs(templates.HeaderFunctions).Execute(buffer, subContext.FullHeader()) - buffer.WriteTo(t) - t.Write([]byte("\n")) - c.buffer.WriteTo(t) - t.Flush() - } else { - c.buffer.WriteTo(c.Output) + if !c.Format.IsTable() { + _, _ = c.buffer.WriteTo(out) + return } + + // Write column-headers and rows to the tab-writer buffer, then flush the output. + tw := tabwriter.NewWriter(out, 10, 1, 3, ' ', 0) + _ = tmpl.Funcs(templates.HeaderFunctions).Execute(tw, subContext.FullHeader()) + _, _ = tw.Write([]byte{'\n'}) + _, _ = c.buffer.WriteTo(tw) + _ = tw.Flush() } func (c *Context) contextFormat(tmpl *template.Template, subContext SubContext) error { From 8a2e52e49838f62cfb8546fd0bfc9927057e8294 Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Fri, 20 Mar 2026 12:03:33 +0100 Subject: [PATCH 3/4] cli/command/formatter: add Format.templateString, remove Context.preFormat The `Context.preFormat` method normalizes the Format as given by the user, and handles (e.g.) stripping the "table" prefix and replacing the "json" format for the actual format (`{{json .}}`). The method used a `finalFormat` field on the Context as intermediate, and was required to be called before executing the format. This patch adds a `Format.templateString()` method that returns the parsed format instead of storing it on the Context. It is currently not exported, but something we could consider in future. Signed-off-by: Sebastiaan van Stijn --- cli/command/formatter/disk_usage.go | 3 - cli/command/formatter/formatter.go | 49 ++++++++-------- cli/command/formatter/formatter_test.go | 75 +++++++++++++++++++++---- 3 files changed, 92 insertions(+), 35 deletions(-) diff --git a/cli/command/formatter/disk_usage.go b/cli/command/formatter/disk_usage.go index b14afc1a06c3..63b15585782e 100644 --- a/cli/command/formatter/disk_usage.go +++ b/cli/command/formatter/disk_usage.go @@ -46,7 +46,6 @@ func (ctx *DiskUsageContext) startSubsection(format Format) (*template.Template, ctx.buffer = &bytes.Buffer{} ctx.header = "" ctx.Format = format - ctx.preFormat() return ctx.parseFormat() } @@ -88,7 +87,6 @@ func (ctx *DiskUsageContext) Write() (err error) { return ctx.verboseWrite() } ctx.buffer = &bytes.Buffer{} - ctx.preFormat() tmpl, err := ctx.parseFormat() if err != nil { @@ -213,7 +211,6 @@ func (ctx *DiskUsageContext) verboseWrite() error { return ctx.verboseWriteTable(duc) } - ctx.preFormat() tmpl, err := ctx.parseFormat() if err != nil { return err diff --git a/cli/command/formatter/formatter.go b/cli/command/formatter/formatter.go index 36872b148370..6291f9cb9acc 100644 --- a/cli/command/formatter/formatter.go +++ b/cli/command/formatter/formatter.go @@ -33,7 +33,7 @@ func (f Format) IsTable() bool { return strings.HasPrefix(string(f), TableFormatKey) } -// IsJSON returns true if the format is the json format +// IsJSON returns true if the format is the JSON format func (f Format) IsJSON() bool { return string(f) == JSONFormatKey } @@ -43,6 +43,29 @@ func (f Format) Contains(sub string) bool { return strings.Contains(string(f), sub) } +// templateString pre-processes the format and returns it as a string +// for templating. +func (f Format) templateString() string { + out := string(f) + switch out { + case TableFormatKey: + // "--format table" + return TableFormatKey + case JSONFormatKey: + // "--format json" only; not JSON formats ("--format '{{json .Field}}'"). + return JSONFormat + } + + // "--format 'table {{.Field}}\t{{.Field}}'" -> "{{.Field}}\t{{.Field}}" + if after, isTable := strings.CutPrefix(out, TableFormatKey); isTable { + out = after + } + + out = strings.Trim(out, " ") // trim spaces, but preserve other whitespace. + out = strings.NewReplacer(`\t`, "\t", `\n`, "\n").Replace(out) + return out +} + // Context contains information required by the formatter to print the output as desired. type Context struct { // Output is the output stream to which the formatted string is written. @@ -53,28 +76,12 @@ type Context struct { Trunc bool // internal element - finalFormat string - header any - buffer *bytes.Buffer -} - -func (c *Context) preFormat() { - c.finalFormat = string(c.Format) - // TODO: handle this in the Format type - switch { - case c.Format.IsTable(): - c.finalFormat = c.finalFormat[len(TableFormatKey):] - case c.Format.IsJSON(): - c.finalFormat = JSONFormat - } - - c.finalFormat = strings.Trim(c.finalFormat, " ") - r := strings.NewReplacer(`\t`, "\t", `\n`, "\n") - c.finalFormat = r.Replace(c.finalFormat) + header any + buffer *bytes.Buffer } func (c *Context) parseFormat() (*template.Template, error) { - tmpl, err := templates.Parse(c.finalFormat) + tmpl, err := templates.Parse(c.Format.templateString()) if err != nil { return nil, fmt.Errorf("template parsing error: %w", err) } @@ -116,8 +123,6 @@ type SubFormat func(func(SubContext) error) error // Write the template to the buffer using this Context func (c *Context) Write(sub SubContext, f SubFormat) error { c.buffer = &bytes.Buffer{} - c.preFormat() - tmpl, err := c.parseFormat() if err != nil { return err diff --git a/cli/command/formatter/formatter_test.go b/cli/command/formatter/formatter_test.go index 6d58aaad36d4..ade5e585d1de 100644 --- a/cli/command/formatter/formatter_test.go +++ b/cli/command/formatter/formatter_test.go @@ -8,20 +8,75 @@ import ( "testing" "gotest.tools/v3/assert" + is "gotest.tools/v3/assert/cmp" ) func TestFormat(t *testing.T) { - f := Format("json") - assert.Assert(t, f.IsJSON()) - assert.Assert(t, !f.IsTable()) - - f = Format("table") - assert.Assert(t, !f.IsJSON()) - assert.Assert(t, f.IsTable()) + tests := []struct { + doc string + f Format + isJSON bool + isTable bool + template string + }{ + { + doc: "json format", + f: "json", + isJSON: true, + isTable: false, + template: JSONFormat, + }, + { + doc: "table format (no template)", + f: "table", + isJSON: false, + isTable: true, + template: TableFormatKey, + }, + { + doc: "table with escaped tabs", + f: "table {{.Field}}\\t{{.Field2}}", + isJSON: false, + isTable: true, + template: "{{.Field}}\t{{.Field2}}", + }, + { + doc: "table with raw string", + f: `table {{.Field}}\t{{.Field2}}`, + isJSON: false, + isTable: true, + template: "{{.Field}}\t{{.Field2}}", + }, + { + doc: "other format", + f: "other", + isJSON: false, + isTable: false, + template: "other", + }, + { + doc: "other with spaces", + f: " other ", + isJSON: false, + isTable: false, + template: "other", + }, + { + doc: "other with newline preserved", + f: " other\n ", + isJSON: false, + isTable: false, + template: "other\n", + }, + } - f = Format("other") - assert.Assert(t, !f.IsJSON()) - assert.Assert(t, !f.IsTable()) + for _, tc := range tests { + t.Run(tc.doc, func(t *testing.T) { + assert.Check(t, is.Equal(tc.f.IsJSON(), tc.isJSON)) + assert.Check(t, is.Equal(tc.f.IsTable(), tc.isTable)) + assert.Check(t, is.Equal(tc.f.templateString(), tc.template)) + }) + } } type fakeSubContext struct { From b0d95d14fac933acd121feb28f934447e93e9193 Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Fri, 20 Mar 2026 13:22:33 +0100 Subject: [PATCH 4/4] cli/command/formatter: NewStats: update GoDoc and add TODO Update the GoDoc to better align with the actual implementation. The "idOrName" is used for fuzzy-matching the container, which can result in multiple stats for the same container: docker ps --format 'table {{.ID}}\t{{.Names}}' CONTAINER ID NAMES b49e6c21d12e quizzical_maxwell docker stats --no-stream quizzical_maxwell b49e6c21d12e b49e6 CONTAINER ID NAME CPU % MEM USAGE / LIMIT MEM % NET I/O BLOCK I/O PIDS b49e6c21d12e quizzical_maxwell 0.10% 140.8MiB / 7.653GiB 1.80% 3.11MB / 13.4kB 115MB / 1.12MB 28 b49e6c21d12e quizzical_maxwell 0.10% 140.8MiB / 7.653GiB 1.80% 3.11MB / 13.4kB 115MB / 1.12MB 28 b49e6c21d12e quizzical_maxwell 0.10% 140.8MiB / 7.653GiB 1.80% 3.11MB / 13.4kB 115MB / 1.12MB 28 We should resolve the canonical ID once, then use that as reference to prevent duplicates. Various parts in the code compare Container against "ID" only (not considering "name" or "ID-prefix"). Signed-off-by: Sebastiaan van Stijn --- cli/command/container/formatter_stats.go | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/cli/command/container/formatter_stats.go b/cli/command/container/formatter_stats.go index 626c63142d91..7a40669931b9 100644 --- a/cli/command/container/formatter_stats.go +++ b/cli/command/container/formatter_stats.go @@ -111,9 +111,14 @@ func NewStatsFormat(source, osType string) formatter.Format { return formatter.Format(source) } -// NewStats returns a new Stats entity and sets in it the given name -func NewStats(container string) *Stats { - return &Stats{StatsEntry: StatsEntry{Container: container}} +// NewStats returns a new Stats entity using the given ID, ID-prefix, or +// name to resolve the container. +func NewStats(idOrName string) *Stats { + // FIXME(thaJeztah): "idOrName" is used for fuzzy-matching the container, which can result in multiple stats for the same container. + // We should resolve the canonical ID once, then use that as reference + // to prevent duplicates. Various parts in the code compare Container + // against "ID" only (not considering "name" or "ID-prefix"). + return &Stats{StatsEntry: StatsEntry{Container: idOrName}} } // statsFormatWrite renders the context for a list of containers statistics