📖 13 min read (~ 2700 words).

Usage as a terminal UI

genspec-tui is an interactive terminal front-end for codescan.

It puts the Go source on the left, the Swagger document that source produces on the right, and the scanner’s diagnostics underneath — all regenerated every time you save.

Its reason to exist is that loop: change an annotation, save, see the spec change.

Predicting what an annotation will produce is the slow part of writing one, and reading a golden file after a build is a poor substitute for watching the node appear.

Beyond the loop, it links the two sides together. You can ask “which Go declaration produced this node?” or “what did this field turn into?” and get an answer by position — not by matching names by eye.

An open Go file on the left, the spec it produces on the right, and the scan’s diagnostics underneath An open Go file on the left, the spec it produces on the right, and the scan’s diagnostics underneath

Note

The TUI is a separate Go module inside the codescan repository, so bubbletea and its dependency tree never reach the library — installing it pulls none of that into your own project. Why the commands are split that way is in The commands.

Install and run

go install github.com/go-openapi/codescan/cmd/genspec-tui@latest
# scan the module in the current directory
genspec-tui

# or point it somewhere, and narrow the scope
genspec-tui -workdir ../my-api ./internal/models/... ./internal/api/...

The selected packages are a positional argument, resolved against -workdir; naming none scans ./....

-packages, taking one comma-separated list, is the older spelling and still works

The TUI registers the same flags as the other commands — one per field of codescan.Options — and reads the same .codescan.yaml, so a session starts where your build leaves off. The flags decide the first scan; almost all of them are also live toggles, below.

The ones worth knowing at the point of starting a session:

FlagDefaultMeaning
-workdir.module directory the scan runs in (WorkDir)
-scan-modelstruealso emit definitions for swagger:model types
-build-tagscomma-separated build tags to apply while loading
-include / -excludepatterns selecting which packages are scanned
-include-tags / -exclude-tagsswagger tags selecting which operations are emitted
-name-from-tagsjsonordered struct tags a field’s name derives from, e.g. form,json for gin. Pass -name-from-tags= (empty) to use the Go field name
-name-concat-budget0.65readability cutoff when deconflicting colliding definition names

Note that -scan-models defaults to on here, where the library’s ScanModels defaults to off. A spec you are browsing in order to see what your types became has little to show without them.

A second group settles what gets built, and how it is loaded — the environment go list would read, plus codescan’s own loader:

FlagDefaultMeaning
-goos / -goarchthis machine’sthe platform the scanned code is built for, so build-tagged files are selected the way that platform selects them
-goflagsdefault go command flags, as GOFLAGS-build-tags wins over a -tags given here
-goworksearch upwardsworkspace selection, as GOWORK: off to ignore a go.work, or the path to one
-goexperimenttoolchain experiments, as GOEXPERIMENT
-loaderautoauto runs go list, as every native build does, and picks own only where no subprocess can be started; go always runs go list; own always uses codescan’s own loader, which needs no toolchain (experimental)
-stub-stdlibfalsesynthesize the standard library instead of reading GOROOT (needs -loader=own)
-compiled-dependenciesfalsetake dependency types from the compiler’s export data instead of reading every dependency from source (needs -loader=go). Worth having here more than anywhere: a session rescans on every save, so the build cache is warm after the first one

Everything the flags set is also a live toggle. Press o for the options popup, space to flip a row, Esc to apply — the spec re-renders on close, which makes the popup the fastest way to find out what a knob such as EmitRefSiblings actually changes. Rows that only bite in combination say so: PruneUnusedModels reads (needs ScanModels) until that one is on.

Being in both places is the point: you can start a session one way and change your mind without restarting. The one option with no route in at all is InputSpec (overlay mode).

Scanning: the edit-save-see loop

The left pane starts as the source tree; Enter opens a file into the viewer, which is read-only and navigable. i turns it into an editor, Ctrl-S saves, a file watcher notices the write, and the spec re-renders. Editing outside the TUI works just as well — disk is the source of truth, and F5 re-reads the open file (asking first, if you have unsaved edits to lose).

A rescan keeps you where you were. The spec cursor is restored to the same node, not the same line number, so a definition that appears above what you are reading does not slide you somewhere else. If the node is gone — you deleted the type — the cursor falls back to its nearest surviving ancestor.

Both panes are syntax-highlighted by the same palette, and in the source viewer a comment gets three classes rather than one, because in a spec generator a comment is not uniformly commentary:

Looks likeReads asWhy
// swagger:model ordera spec keythe annotation declares the thing; it is the input that produced the pane opposite
// required: truea keywordgrammar the parser acts on, in the class Go’s own type and func get
// the id of the orderdimmed prosefreeform description

What lights up as a keyword comes from the parser’s own table, so aliases (minminimum) and letter case are free — and what is highlighted is what the parser will actually act on.

Tracking: from a spec node back to the code

Follow mode: a spec node highlighted beside the source line that produced it, with the badge naming the resolved target Follow mode: a spec node highlighted beside the source line that produced it, with the badge naming the resolved target

f turns on a persistent link between panes. The pane you pressed it in is the driver and keeps focus; the others mirror it on every cursor move, centring and highlighting the linked line. A SPEC ▸ SOURCE badge names the direction and the target it resolved to.

It works in three directions — spec → source, source → spec, and a diagnostic to both. Esc, a second f, changing focus, or starting to edit all leave it.

Two indexes, rebuilt on every render, meet at a JSON pointer: one maps each rendered spec line to the pointer of the node on it, the other maps pointers back to Go source positions through codescan’s OnProvenance callback. That is why the answer is exact rather than a name match.

The gutter marks which lines actually lead somewhere, so you can see what is navigable without probing for it:

MarkerIn the spec paneIn the source viewer
this node has a source position of its ownthis line produced a spec node
a followable $ref; Enter goes to its definition

Only exact anchors are marked. Nearly every line resolves to something through its nearest anchored ancestor, so marking those would dot the whole document and tell you nothing.

F3 steps through the places the node under the cursor is referenced, wrapping; shift+F3 goes back; Enter follows a $ref to its definition.

Diagnostics: what the scanner made of your annotations

The diagnostics pane under a severity tally, with the tokens the findings name underlined in the source viewer The diagnostics pane under a severity tally, with the tokens the findings name underlined in the source viewer

Whether your annotations were understood is a different question from whether the document they produced is well formed, and the pane at the bottom answers the first one. Under a one-line severity tally it lists everything the scan observed, in source order, each row carrying its severity’s colour so the pane can be read for red at a glance. Enter jumps to the source line a finding names and focuses it; f makes the selection drive both other panes at once.

Findings are also drawn at the site: the token a diagnostic names is underlined in the severity’s colour, in the source viewer itself. The pane tells you what and where; the underline tells you which token, without leaving the line you are reading.

Marks are re-derived on every rescan, so they never outlive the finding that produced them.

The validation tab of the diagnostics pane, listing what go-openapi/validate found, each by its path The validation tab of the diagnostics pane, listing what go-openapi/validate found, each by its path

v runs the generated document through go-openapi/validate and lists what it finds in a validation tab of the diagnostics pane. V switches between that tab and the scan’s own findings.

This is the second of the two questions above, and answering both is why they are tabs rather than one list: a scan can be perfectly clean and still produce something a consumer rejects.

They also track different things. A scan diagnostic carries a source position, so it drives the source pane. A validation finding carries only a JSON pointer, so Enter and f there drive the spec pane and nothing else.

The tab exists only once you have pressed v, and a rescan retires it: those findings judged a document that has just been replaced, and a list of complaints about a spec that no longer exists invites navigating to nodes that may have moved or gone. Press v again.

Where a finding lands

A finding carries the JSON pointer the validator recorded as it walked, so navigation is exact. Indexed paths included — /paths/~1pets/get/parameters/0/type lands on that parameter, not on the list — and so are faults reached through a $ref, which are reported against the shared definition that actually holds them.

A finding about something the document lacks is reported on the value that should hold it: a response missing its description lands on the response, and a document missing its info block lands on the document, which Enter takes you to the top of.

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.

The run-cost card: a split line recapping time and memory as ratios, then elapsed, allocated, retained, live objects, GC cycles and memory held from the OS — each split between scanning and rendering The run-cost card: a split line recapping time and memory as ratios, then elapsed, allocated, retained, live objects, GC cycles and memory held from the OS — each split between scanning and rendering

Time and memory are split between scanning and rendering the document, and recapped as ratios on the split line, because the question a reader arrives with is usually which phase is this? The two can disagree sharply, which is why both are there.

Allocated counts everything the run churned through, garbage included; retained counts what it left behind. A scan that allocates half a gigabyte and retains a few megabytes is not the same problem as one that keeps what it takes, and one number could not tell you which you have.

Two things the card says about itself, worth repeating: the window is process-wide, so the redraw loop and the file watcher are in the figures; and a rescan holds two documents at once, so the retained figure reads high by about one. That is arithmetic, not a leak.

A scalar cannot say who spent it, which is what -profile is for. Start the session with it and each scan is profiled as well as timed, so the card also reports where the CPU went and what allocated it, by function and per phase:

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 profiled card: the same figures plus a cpu ratio, then a “where the CPU went” table charging each sample to the call of ours that led there, and a “what allocated it” table by function The profiled card: the same figures plus a cpu ratio, then a “where the CPU went” table charging each sample to the call of ours that led there, and a “what allocated it” table by function

CPU is charged to the call of ours that led there, not to the leaf frame. A leaf answers “what was executing”, which for this program is mostly the allocator and the collector — true, and nothing anyone can act on. Charged our way, a row names the boundary where codescan hands the work to somebody else’s code, and covers everything under it.

Profiling is not free, and the card says so rather than letting you compare across runs by accident: under -profile the scalars at the top carry the profiler’s own overhead and its collections, while the tables below exclude them. The card scrolls when it outgrows the terminal (↑↓/jk, PgUp/PgDn, Home/End).

It names the .pprof files it wrote and the go tool pprof commands that open them. All three flags are addressed in the profile section of a .codescan.yaml.

Looking up an annotation

The annotation reference popup: the annotation, what it does, its syntax, and the keywords its body accepts The annotation reference popup: the annotation, what it does, its syntax, and the keywords its body accepts

With the viewer’s navigation line on a comment carrying a swagger: directive, K shows what that annotation does: its syntax, a one-line summary, and what may be written in its body. Clicking the directive does the same.

K because it is vim’s “look up what is under the cursor”, and what LSP clients bind hover to. It reads the buffer, so it works on an annotation you are still typing — which is when you want it.

The popup covers all twenty swagger: annotations, and each entry names the family of keywords its body accepts rather than listing them: there are far too many individual keywords for a popup to be the right place for them. For those, and for worked examples of every annotation, the reference on this site is the long form:

  • Annotations — one page per annotation, with grammar and live examples
  • Keyword reference — every keyword, grouped by the family it belongs to

Keys worth knowing

The binding surface is context-dependent — f follows from three different panes, Enter opens a file in the tree but follows a $ref in the spec — so the header carries a standing h: help banner.

KeyAction
h / ?the full keymap, grouped by pane
Tab / clickfocus a pane (the wheel scrolls whichever pane is under the pointer)
ctrl+arrowsmove either divider, in the arrow’s own direction
ffollow mode
Kwhat the swagger: annotation on this line means
v / Vvalidate the spec / switch diagnostics tab
oscanner options
mwhat the last scan cost
r / F5rescan now / re-read the open file from disk
ccopy the focused pane to the clipboard
ctrl+qquit

Everything else — the per-pane bindings, the editor, the popups — is in the h overlay and in the module README, which documents the internals as well.

Worth knowing before you rely on it

These limits are deliberate; the TUI reports them rather than guessing.

The editor rewrites whitespace on save

bubbles/textarea expands tabs to four spaces when a file is loaded and treats a lone CR as a line break, and neither has an exported knob. Everything the TUI shows you agrees with itself because it all reads the same normalised text — but Ctrl-S writes the buffer, so saving re-indents a tab-indented file with spaces and rewrites CRLF endings as LF. Edit and save here only when you are content with that; otherwise edit in your own editor and let the watcher pick the change up.

  • Not every node has source. codescan anchors code-detail nodes — type declarations, fields, values, route and meta blocks — and finer nodes resolve to their nearest anchored ancestor. A node with no anchored ancestor at all was not produced from code (an InputSpec overlay node, say); the follower holds position and says so instead of jumping somewhere plausible.
  • Positions are as of the last scan. With unsaved edits in the buffer every anchor below the edit has shifted, so follow shows a STALE badge. Saving triggers a rescan and clears it.
  • $ref resolution is a site index, not a resolver. Local #/… refs are followable; a ref into another file or a URL is reported as external rather than chased.
  • Some keys are terminal-dependent. shift+F3 and ctrl+arrow rely on sequences most modern emulators send and a few (notably inside a default tmux) do not. Where they are missing nothing misfires — there is simply no previous-reference key, and no resize keys.
  • Split sizes last for the session only. They survive rescans and terminal resizes, but not a restart. The TUI does read a .codescan.yaml — but what a file can set is flags, and where the splits sit is not one of them.

The module README carries the full list.

What’s next

  • Usage as a library — drive the same scanner from your own program, a go:generate step, or a test.
  • Usage as a headless CLI — the same scan in a build, with the settings you converged on here.
  • Options reference — every knob the o popup toggles, and what it does to the document.
  • Tutorials — annotate a package from meta to definitions, with the TUI open beside you.