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
95 changes: 95 additions & 0 deletions cmd/genspec-tui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,15 @@ the scan runs under, plus codescan's own loader:
| `-toolchain-free-loader` | `false` | load packages with codescan's own loader instead of the go command (experimental) |
| `-stub-stdlib` | `false` | synthesize the standard library instead of reading GOROOT (needs `-toolchain-free-loader`) |

A third group observes the run rather than configuring it — see
[What a scan cost](#what-a-scan-cost-m):

| Flag | Default | Meaning |
|------|---------|---------|
| `-profile` | `false` | profile every scan: a CPU profile and per-phase allocation profiles, reported under `m` and written for `go tool pprof` |
| `-profile-dir` | a fresh temp dir | where `-profile` writes its `.pprof` files |
| `-mem-profile-rate` | `0` | with `-profile`, `runtime.MemProfileRate`: `0` samples every 512 KiB, `1` records every allocation exactly |

Every boolean scanner option can be toggled live with `o`; the spec re-renders
on close, which makes the popup the fastest way to see what a flag such as
`EmitRefSiblings` actually changes. The rows are grouped (discovery & scope ·
Expand Down Expand Up @@ -137,6 +146,7 @@ the cursor falls back to its nearest surviving ancestor.
| `v` / `V` | validate the generated spec / switch the diagnostics pane between scan and validation |
| `F5` | reload the open file from disk (asks before discarding unsaved edits) |
| `o` | scanner options popup (`space` toggles, `Esc`/`o` applies and rescans) |
| `m` | what the last scan cost — wall clock and memory, split between scanning and rendering; the profiles too under `-profile` |
| `ctrl+q` / `ctrl+c` | quit |

### Spec pane
Expand Down Expand Up @@ -213,6 +223,91 @@ The entries cover the twenty `swagger:` annotations. Individual body keywords
too many for a popup to be the right place — so each annotation's entry says
instead which family of keywords its body accepts.

## What a scan cost (`m`)

`m` opens a card describing the run that just finished: how long it took, what
it allocated, what it left live, and how much the process holds from the OS.
Time and memory are both split between **scanning** (codescan) and **rendering**
(serializing the same document twice, as JSON and again as YAML), because the
run is fenced three times — before the scan, after it, and after rendering. Under
`-profile` the same two phases are bracketed by the profiler, so what the sampler
says and what the fences say describe the same halves of the same work.

A `split` line at the top recaps the whole thing as ratios — `time 96% / 4% ·
memory 86% / 14%`, and `cpu` too under `-profile` — because the question a reader
usually arrives with is *which phase is this?*, and two figures in different
units is a comparison they would otherwise do in their head. Time and memory can
disagree sharply, which is why both are there.

It is a modal rather than another status-line field: the status line already
carries the pane hints, the follow badge and the search prompt, and this is a
figure most sessions never ask for.

Two things those figures are not, both stated on the card:

- **The window is process-wide.** `runtime.ReadMemStats` accounts for the whole
process, and while a scan runs the redraw loop, the spinner and the file
watcher allocate on other goroutines.
- **A rescan holds two documents.** The previous spec is only released once the
new one lands, so on a rescan the retained figure reads high by about one
document. That is arithmetic, not a leak.

### Profiled runs (`-profile`)

A scalar cannot say who spent it, which is the ceiling on everything above.
Start with `-profile` and each scan is also profiled: the card then reports
**where the CPU went** and **what allocated it, per phase**, by function. The
process-wide caveat stops mattering, because the noise arrives named — a redraw
row you discount rather than a confound you cannot separate.

```sh
genspec-tui -profile -workdir ../my-api

# every allocation counted rather than sampled every 512 KiB — accurate, and slow
genspec-tui -profile -mem-profile-rate=1 -workdir ../my-api
```

The card grows scrolling when it outgrows the terminal (`↑↓`/`jk`, `PgUp`/`PgDn`,
`Home`/`End`), and names the artifacts it wrote, with the commands that open them:

```sh
go tool pprof -http=: <dir>/cpu-scan.pprof
go tool pprof -http=: -base <dir>/mem-before.pprof <dir>/mem-after-scan.pprof
```

Five artifacts are written: a CPU profile per phase (`cpu-scan`, `cpu-render`)
and three heap snapshots — before, after scanning, after rendering — so `-base`
reproduces either phase in the real tool. What the in-TUI tables give you
is the answer without leaving the session; the flame graph is one command away.

Caveats worth knowing before reading a table:

- **Sampling.** At the default rate the heap profiler records one allocation per
512 KiB, and the figures are scaled back up the way pprof does. The card says
`estimated from sampling`, or `every allocation counted` under
`-mem-profile-rate=1`.
- **Samples, not measurements.** The CPU profiler runs at 100 Hz, so a scan under
a second yields a few dozen samples and cannot rank functions; the card says so
rather than dressing a handful of samples up as a ranking. Both phases are
bracketed, but rendering is often shorter than the sampler's 10 ms interval and
simply catches nothing — which the card states, because an absent section reads
as an oversight while "nothing sampled" is a measurement.
- **The observer is excluded.** Stopping one phase's CPU profile flushes it
through a compressor and starting the next allocates its buffers, all of it
between two fences. Those allocations are dropped from the tables rather than
reported: in the short phase they would otherwise crowd out its real sites.
- **Observing costs.** A profiled run collects at each fence and carries the
sampler's overhead, so the summary figures at the top of the card are worse
than the same run unprofiled. The tables are the accurate account.
- **Not a runtime toggle.** The heap sampling rate is a property of the process
and is fixed at launch: two runs in one session are then comparable.
- **One at a time.** The CPU profiler is a process-wide singleton, so if a second
scan starts while one is in flight (changing an option mid-scan), it runs
without a CPU profile and says so.
- **The last run only.** Every scan writes the same five filenames, so a rescan
overwrites its predecessor. Copy them elsewhere before saving a file if you want
to compare two runs.

## Reloading

`F5` re-reads the open file from disk. Disk is the source of truth here, so
Expand Down
1 change: 1 addition & 0 deletions cmd/genspec-tui/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ require (
github.com/go-openapi/strfmt v0.27.0
github.com/go-openapi/testify/v2 v2.6.0
github.com/go-openapi/validate v0.26.3
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3
github.com/muesli/termenv v0.16.0
go.yaml.in/yaml/v3 v3.0.5
)
Expand Down
2 changes: 2 additions & 0 deletions cmd/genspec-tui/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
Expand Down
22 changes: 2 additions & 20 deletions cmd/genspec-tui/internal/ux/chrome.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@ package ux

import (
"fmt"
"time"

"github.com/charmbracelet/lipgloss"

"github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux/humanize"
"github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux/theme"
)

Expand Down Expand Up @@ -56,7 +56,7 @@ func (m *Model) headerLine() string {
case m.scan.Running:
tail = m.scan.Spin.View() + theme.Status().Render("scanning")
case m.scan.Elapsed > 0:
tail = theme.Status().Render("ready (" + humanDuration(m.scan.Elapsed) + ")")
tail = theme.Status().Render("ready (" + humanize.Duration(m.scan.Elapsed) + ")")
}

// Measured, not guessed. Assembled around the work dir rather than through a format string, so nothing in the data
Expand All @@ -74,24 +74,6 @@ func (m *Model) headerLine() string {
return lipgloss.NewStyle().MaxWidth(m.width).Render(line)
}

// humanDuration renders d compactly: "947ms", "3s", "1m 3s" (minute form drops a zero-second remainder, e.g. "2m").
func humanDuration(d time.Duration) string {
switch {
case d < time.Second:
return fmt.Sprintf("%dms", d.Milliseconds())
case d < time.Minute:
return fmt.Sprintf("%ds", int(d.Round(time.Second).Seconds()))
default:
d = d.Round(time.Second)
mins := int(d / time.Minute)
secs := int((d % time.Minute) / time.Second)
if secs == 0 {
return fmt.Sprintf("%dm", mins)
}
return fmt.Sprintf("%dm %ds", mins, secs)
}
}

// statusLine renders the bottom line, clipped to the terminal.
//
// Same reasoning as the header: several of its variants embed something unbounded - a JSON pointer, a follow target, a
Expand Down
2 changes: 1 addition & 1 deletion cmd/genspec-tui/internal/ux/diagnostics_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,7 @@ func TestDoScanCollectsDiagnostics(t *testing.T) {
WorkDir: dir,
Packages: []string{"."},
ScanModels: true,
})
}, scan.Profiling{})

if res.Err != nil {
t.Fatalf("scan should not hard-fail on soft diagnostics: %v", res.Err)
Expand Down
3 changes: 2 additions & 1 deletion cmd/genspec-tui/internal/ux/fixtures_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (

"github.com/go-openapi/codescan"
"github.com/go-openapi/codescan/cmd/genspec-tui/internal/index"
"github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux/scan"
"github.com/go-openapi/codescan/internal/parsers/grammar"
"github.com/go-openapi/codescan/internal/scanner"
)
Expand All @@ -31,7 +32,7 @@ func testModel(t *testing.T, opts ...modelOpt) *Model {
func testModelIn(t *testing.T, dir string, opts ...modelOpt) *Model {
t.Helper()

m := New(codescan.Options{WorkDir: dir, Packages: []string{"./..."}})
m := New(codescan.Options{WorkDir: dir, Packages: []string{"./..."}}, scan.Profiling{})
t.Cleanup(m.Close)

m.width, m.height = 80, 24
Expand Down
1 change: 1 addition & 0 deletions cmd/genspec-tui/internal/ux/help/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ var helpSections = []helpSection{
{"v", "validate the generated spec (go-openapi/validate)"},
{"V", "switch the diagnostics pane between scan and validation"},
{"o", "scanner options"},
{"m", "what the last scan cost (time, memory, and the profiles under -profile)"},
{"ctrl+q ctrl+c", "quit"},
}},
{"spec pane", []helpEntry{
Expand Down
8 changes: 8 additions & 0 deletions cmd/genspec-tui/internal/ux/humanize/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers
// SPDX-License-Identifier: Apache-2.0

// Package humanize renders durations, sizes and counts the way the chrome shows them to a reader.
//
// Its own package because the header line and the run-cost overlay both need the same spellings, and neither owns the
// other: a duration that reads "1m 3s" in one place and "63s" in the other is the same measurement told two ways.
package humanize
86 changes: 86 additions & 0 deletions cmd/genspec-tui/internal/ux/humanize/humanize.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers
// SPDX-License-Identifier: Apache-2.0

package humanize

import (
"fmt"
"time"
)

// Duration renders d compactly: "947ms", "3s", "1m 3s" (minute form drops a zero-second remainder, e.g. "2m").
func Duration(d time.Duration) string {
switch {
case d < time.Second:
return fmt.Sprintf("%dms", d.Milliseconds())
case d < time.Minute:
return fmt.Sprintf("%ds", int(d.Round(time.Second).Seconds()))
default:
d = d.Round(time.Second)
mins := int(d / time.Minute)
secs := int((d % time.Minute) / time.Second)
if secs == 0 {
return fmt.Sprintf("%dm", mins)
}

return fmt.Sprintf("%dm %ds", mins, secs)
}
}

// The binary units memory is reported in. A megabyte here is 1024 KB, matching what every other tool a reader would
// compare this against (top, ps, the Go runtime's own docs) means by MB.
const (
kb = 1 << 10
mb = 1 << 20
gb = 1 << 30
)

// Bytes renders a size: "912 B", "44 KB", "412.3 MB", "1.21 GB".
//
// Precision grows with the unit rather than staying fixed, because the reader's question changes with it: at kilobytes
// nobody cares about a fraction, while at gigabytes two decimals are the difference between two readings.
func Bytes(n uint64) string {
switch {
case n < kb:
return fmt.Sprintf("%d B", n)
case n < mb:
return fmt.Sprintf("%.0f KB", float64(n)/kb)
case n < gb:
return fmt.Sprintf("%.1f MB", float64(n)/mb)
default:
return fmt.Sprintf("%.2f GB", float64(n)/gb)
}
}

// SignedBytes renders a difference, always carrying its sign: "+38.0 MB", "-4.1 MB", "+0 B".
//
// The sign is explicit even when positive: these are read as changes, and a bare number invites being read as a total.
func SignedBytes(n int64) string {
if n >= 0 {
return "+" + Bytes(uint64(n))
}

// Negating MinInt64 overflows. Adding one before the negation keeps it in range, at the cost of a single byte at a
// magnitude no heap difference can reach.
//
//nolint:gosec // -(n+1) is non-negative for every n < 0, which is the only branch this is reached on
return "-" + Bytes(uint64(-(n+1))+1)
}

// SignedCount renders a count difference in short form: "+410 k", "-1.2 M", "+37".
func SignedCount(n int64) string {
sign := "+"
if n < 0 {
sign = "-"
n = -n
}

switch {
case n < 1_000:
return fmt.Sprintf("%s%d", sign, n)
case n < 1_000_000:
return fmt.Sprintf("%s%.0f k", sign, float64(n)/1_000)
default:
return fmt.Sprintf("%s%.1f M", sign, float64(n)/1_000_000)
}
}
83 changes: 83 additions & 0 deletions cmd/genspec-tui/internal/ux/humanize/humanize_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers
// SPDX-License-Identifier: Apache-2.0

package humanize_test

import (
"testing"
"time"

"github.com/go-openapi/testify/v2/assert"

"github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux/humanize"
)

func TestDuration(t *testing.T) {
for _, tc := range []struct {
in time.Duration
want string
}{
{947 * time.Millisecond, "947ms"},
{0, "0ms"},
{time.Second, "1s"},
{3200 * time.Millisecond, "3s"},
{59 * time.Second, "59s"},
{time.Minute, "1m"},
{2 * time.Minute, "2m"},
{63 * time.Second, "1m 3s"},
} {
assert.Equal(t, tc.want, humanize.Duration(tc.in), "%s", tc.in)
}
}

func TestBytes(t *testing.T) {
for _, tc := range []struct {
in uint64
want string
}{
{0, "0 B"},
{912, "912 B"},
{1023, "1023 B"},
{1024, "1 KB"},
{1024*1024 - 1, "1024 KB"}, // rounds up to the next unit's worth without changing unit
{1024 * 1024, "1.0 MB"},
{432 * 1024 * 1024, "432.0 MB"},
{1024 * 1024 * 1024, "1.00 GB"},
{3*1024*1024*1024 + 512*1024*1024, "3.50 GB"},
} {
assert.Equal(t, tc.want, humanize.Bytes(tc.in), "%d", tc.in)
}
}

func TestSignedBytes(t *testing.T) {
for _, tc := range []struct {
in int64
want string
}{
{0, "+0 B"},
{38 * 1024 * 1024, "+38.0 MB"},
{-4 * 1024 * 1024, "-4.0 MB"},
{-512, "-512 B"},
} {
assert.Equal(t, tc.want, humanize.SignedBytes(tc.in), "%d", tc.in)
}

// A heap difference can never reach here, but arithmetic that wraps at the extreme is a bug wherever it lives.
assert.NotPanics(t, func() { humanize.SignedBytes(-1 << 62) })
}

func TestSignedCount(t *testing.T) {
for _, tc := range []struct {
in int64
want string
}{
{0, "+0"},
{37, "+37"},
{-999, "-999"},
{410_000, "+410 k"},
{-12_400, "-12 k"},
{1_250_000, "+1.2 M"},
} {
assert.Equal(t, tc.want, humanize.SignedCount(tc.in), "%d", tc.in)
}
}
Loading
Loading