From 424537dea0fd35c50e236c69169e506f4443e0ba Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Fri, 31 Jul 2026 15:35:47 +0200 Subject: [PATCH 1/3] feat(genspec-tui): interactive TUI that live-renders a spec from source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A terminal front-end for codescan: point it at a package tree and it shows the Go source and the Swagger spec that source produces, side by side, rescanning as the files change. It is a self-contained module under cmd/genspec-tui so the library takes on none of its dependencies. Four panes — a source tree, a file view, the generated spec, and the scanner's diagnostics — over a debounced watcher that rescans on save and re-renders without losing your place. The spec pane renders JSON or YAML, searches, and keeps a line cursor that survives a rebuild by remembering the NODE it was on rather than the line number, which changes on every render. Cross-reference navigation is the reason the two halves sit together. The scanner reports, for each spec node it produces, the source position of the Go construct that produced it; that stream is indexed both ways against the exact bytes each pane draws, so a spec node resolves to its source and a source line resolves to the node it produced. From there: follow mode, where one pane drives and the other mirrors; a one-shot jump; find-references over a definition's use sites; go-to-definition on a local $ref; and a gutter marking which lines actually lead somewhere. Because every one of those is keyed on a line number, the file, the buffer, the indexes and the marks must agree on what a line is. The editor widget treats a lone CR as a line break, so a file with Windows endings would otherwise load as twice as many lines with a blank between each, and every coordinate below the first CR would name a line a growing distance from the one meant. Content is normalised to LF on the way in, giving all four one shared notion. The linker answers honestly when it cannot help. A node with no anchored ancestor was never produced from code — an overlay node legitimately has no origin — and says so instead of jumping somewhere plausible. A node that resolves but is not rendered in the active view says that instead. With unsaved edits in the buffer every position below the edit has moved, so follow mode shows a staleness badge until a save triggers a rescan. Both panes are syntax-highlighted with no highlighting library involved. The spec pane reuses the lexer that already builds its line-to-pointer index, so classification is a third product of one walk. The source pane uses go/scanner, which is error tolerant, so a buffer you are halfway through editing still highlights. Comments there get three classes rather than one, because in a spec generator a comment is not uniformly commentary: a `swagger:` line is the annotation that declares the thing, a leading grammar keyword is grammar the parser acts on, and the rest is prose. Keyword recognition goes through the parser's own table, so aliases and letter case come for free. The scanner's diagnostics are drawn on the token they name, so a finding is visible on the line you are reading rather than only in the pane below. Highlighting composes by (line, column, kind) spans rather than pre-coloured strings: the raw text is truncated at rune boundaries first and styled last, which is the only order in which a narrow pane cannot cut through an escape sequence. Positions are translated between the file's byte columns and the buffer's rune columns, since the editor widget substitutes spaces for tabs. Scanner options are editable at runtime through a grouped overlay that marks a toggle moot when the option it depends on is off, and every value-typed option is a CLI flag. A key-bindings overlay behind `h` or `?` documents the lot, with a header chip pointing at it. Diagnostics are navigable and jump to source like every other pane, and every navigable pane pages. Known limits are documented in the module README rather than left to be discovered: editing is a plain textarea that normalises tabs to spaces and CRLF endings to LF when saving, edit mode is not highlighted, and $ref resolution is a site index rather than a resolver, so chains and external refs are reported rather than chased. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Frederic BIDON --- README.md | 22 +- cmd/genspec-tui/README.md | 332 +++ cmd/genspec-tui/go.mod | 55 + cmd/genspec-tui/go.sum | 99 + .../internal/ux/diagnostics_render.go | 126 ++ .../internal/ux/diagnostics_render_test.go | 139 ++ .../internal/ux/gadgets/clipboard.go | 91 + cmd/genspec-tui/internal/ux/help.go | 145 ++ cmd/genspec-tui/internal/ux/help_test.go | 218 ++ .../internal/ux/index/diagmarks.go | 69 + .../internal/ux/index/diagmarks_test.go | 90 + .../internal/ux/index/gohighlight.go | 282 +++ .../internal/ux/index/gohighlight_test.go | 333 +++ .../internal/ux/index/highlight.go | 103 + .../internal/ux/index/highlight_test.go | 134 ++ cmd/genspec-tui/internal/ux/index/refindex.go | 223 ++ .../internal/ux/index/refindex_test.go | 190 ++ .../internal/ux/index/sourceindex.go | 147 ++ .../internal/ux/index/sourceindex_test.go | 130 ++ .../internal/ux/index/specindex.go | 114 ++ .../internal/ux/index/specindex_test.go | 129 ++ cmd/genspec-tui/internal/ux/key/bindings.go | 70 + cmd/genspec-tui/internal/ux/main_test.go | 23 + cmd/genspec-tui/internal/ux/model.go | 1805 +++++++++++++++++ .../internal/ux/model_diag_test.go | 124 ++ .../internal/ux/model_diagmarks_test.go | 266 +++ .../internal/ux/model_diagnav_test.go | 163 ++ cmd/genspec-tui/internal/ux/model_e2e_test.go | 239 +++ .../internal/ux/model_edges_test.go | 284 +++ .../internal/ux/model_follow_test.go | 106 + .../internal/ux/model_gosyntax_test.go | 250 +++ .../internal/ux/model_gutter_test.go | 169 ++ .../internal/ux/model_join_test.go | 490 +++++ .../internal/ux/model_options_test.go | 236 +++ .../internal/ux/model_paging_test.go | 138 ++ .../internal/ux/model_refs_test.go | 401 ++++ .../internal/ux/model_rescan_test.go | 214 ++ .../internal/ux/model_syntax_test.go | 140 ++ .../internal/ux/panels/diagnostics.go | 78 + .../internal/ux/panels/fileview.go | 298 +++ .../ux/panels/fileview_syntax_test.go | 119 ++ .../internal/ux/panels/fileview_test.go | 91 + .../internal/ux/panels/gutter_test.go | 103 + .../internal/ux/panels/main_test.go | 24 + .../internal/ux/panels/navvisuals_test.go | 156 ++ cmd/genspec-tui/internal/ux/panels/spec.go | 328 +++ .../internal/ux/panels/spec_test.go | 82 + cmd/genspec-tui/internal/ux/panels/syntax.go | 65 + .../internal/ux/panels/syntax_test.go | 98 + cmd/genspec-tui/internal/ux/panels/tree.go | 315 +++ cmd/genspec-tui/internal/ux/scan.go | 94 + cmd/genspec-tui/internal/ux/theme/theme.go | 200 ++ cmd/genspec-tui/internal/ux/watcher.go | 98 + cmd/genspec-tui/main.go | 175 ++ cmd/genspec-tui/main_test.go | 179 ++ docs/genspec-tui.png | Bin 0 -> 352656 bytes 56 files changed, 10790 insertions(+), 2 deletions(-) create mode 100644 cmd/genspec-tui/README.md create mode 100644 cmd/genspec-tui/go.mod create mode 100644 cmd/genspec-tui/go.sum create mode 100644 cmd/genspec-tui/internal/ux/diagnostics_render.go create mode 100644 cmd/genspec-tui/internal/ux/diagnostics_render_test.go create mode 100644 cmd/genspec-tui/internal/ux/gadgets/clipboard.go create mode 100644 cmd/genspec-tui/internal/ux/help.go create mode 100644 cmd/genspec-tui/internal/ux/help_test.go create mode 100644 cmd/genspec-tui/internal/ux/index/diagmarks.go create mode 100644 cmd/genspec-tui/internal/ux/index/diagmarks_test.go create mode 100644 cmd/genspec-tui/internal/ux/index/gohighlight.go create mode 100644 cmd/genspec-tui/internal/ux/index/gohighlight_test.go create mode 100644 cmd/genspec-tui/internal/ux/index/highlight.go create mode 100644 cmd/genspec-tui/internal/ux/index/highlight_test.go create mode 100644 cmd/genspec-tui/internal/ux/index/refindex.go create mode 100644 cmd/genspec-tui/internal/ux/index/refindex_test.go create mode 100644 cmd/genspec-tui/internal/ux/index/sourceindex.go create mode 100644 cmd/genspec-tui/internal/ux/index/sourceindex_test.go create mode 100644 cmd/genspec-tui/internal/ux/index/specindex.go create mode 100644 cmd/genspec-tui/internal/ux/index/specindex_test.go create mode 100644 cmd/genspec-tui/internal/ux/key/bindings.go create mode 100644 cmd/genspec-tui/internal/ux/main_test.go create mode 100644 cmd/genspec-tui/internal/ux/model.go create mode 100644 cmd/genspec-tui/internal/ux/model_diag_test.go create mode 100644 cmd/genspec-tui/internal/ux/model_diagmarks_test.go create mode 100644 cmd/genspec-tui/internal/ux/model_diagnav_test.go create mode 100644 cmd/genspec-tui/internal/ux/model_e2e_test.go create mode 100644 cmd/genspec-tui/internal/ux/model_edges_test.go create mode 100644 cmd/genspec-tui/internal/ux/model_follow_test.go create mode 100644 cmd/genspec-tui/internal/ux/model_gosyntax_test.go create mode 100644 cmd/genspec-tui/internal/ux/model_gutter_test.go create mode 100644 cmd/genspec-tui/internal/ux/model_join_test.go create mode 100644 cmd/genspec-tui/internal/ux/model_options_test.go create mode 100644 cmd/genspec-tui/internal/ux/model_paging_test.go create mode 100644 cmd/genspec-tui/internal/ux/model_refs_test.go create mode 100644 cmd/genspec-tui/internal/ux/model_rescan_test.go create mode 100644 cmd/genspec-tui/internal/ux/model_syntax_test.go create mode 100644 cmd/genspec-tui/internal/ux/panels/diagnostics.go create mode 100644 cmd/genspec-tui/internal/ux/panels/fileview.go create mode 100644 cmd/genspec-tui/internal/ux/panels/fileview_syntax_test.go create mode 100644 cmd/genspec-tui/internal/ux/panels/fileview_test.go create mode 100644 cmd/genspec-tui/internal/ux/panels/gutter_test.go create mode 100644 cmd/genspec-tui/internal/ux/panels/main_test.go create mode 100644 cmd/genspec-tui/internal/ux/panels/navvisuals_test.go create mode 100644 cmd/genspec-tui/internal/ux/panels/spec.go create mode 100644 cmd/genspec-tui/internal/ux/panels/spec_test.go create mode 100644 cmd/genspec-tui/internal/ux/panels/syntax.go create mode 100644 cmd/genspec-tui/internal/ux/panels/syntax_test.go create mode 100644 cmd/genspec-tui/internal/ux/panels/tree.go create mode 100644 cmd/genspec-tui/internal/ux/scan.go create mode 100644 cmd/genspec-tui/internal/ux/theme/theme.go create mode 100644 cmd/genspec-tui/internal/ux/watcher.go create mode 100644 cmd/genspec-tui/main.go create mode 100644 cmd/genspec-tui/main_test.go create mode 100644 docs/genspec-tui.png diff --git a/README.md b/README.md index 4ff7b766..6485bf54 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,9 @@ Supports Go modules (since go1.11). ## Announcements -* **2025-04-19** : large package layout reshuffle +* **2026-07-31** : landed a new spec generation TUI tool + +* **2026-04-19** : large package layout reshuffle * the entire project is being refactored to restore a reasonable level of maintenability * the only exposed API is Run() and Options. @@ -32,7 +34,7 @@ API is stable. go get github.com/go-openapi/codescan ``` -## Basic usage +## Basic usage as a library ```go import ( @@ -44,6 +46,22 @@ swaggerSpec, err := codescan.Run(&codescan.Options{ }) ``` +## Work with the TUI + +This project comes with a terminal UI to quickly render a Swagger spec from source +and navigate your code annotations. It shows diagnostics and you may test the impact +of the various available options. + +```cmd +go install github.com/go-openapi/codescan/cmd/genspec-tui@latest +``` + +```cmd +genspec-tui -workdir [my source location] +``` + +![tui_screenshot](docs/genspec-tui.png) + ## Change log See diff --git a/cmd/genspec-tui/README.md b/cmd/genspec-tui/README.md new file mode 100644 index 00000000..adfe0707 --- /dev/null +++ b/cmd/genspec-tui/README.md @@ -0,0 +1,332 @@ + + +# genspec-tui + +An interactive terminal front-end for [codescan][codescan]: browse a Go source +tree on the left, watch the Swagger spec it produces on the right, and see the +scanner's diagnostics underneath — all regenerated on every save. + +Its reason to exist is the loop: change an annotation, hit save, see the spec +change. Beyond that it links the two sides together, so you can ask "which Go +code produced this node?" and "what did this field turn into?" and get an +answer by position rather than by guessing at names. + +Audience: codescan/go-swagger maintainers and contributors. + +## Install and run + +`genspec-tui` is a **separate Go module** inside the codescan repo, so +bubbletea and its dependency tree never reach the lean library. + +```sh +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 -packages ./internal/models/...,./internal/api/... +``` + +From a checkout, the repo's `go.work` wires the module to the local library: + +```sh +go run ./cmd/genspec-tui -workdir ./fixtures -packages ./goparsing/petstore/... +``` + +| Flag | Default | Meaning | +|------|---------|---------| +| `-workdir` | `.` | module directory the scan runs in (codescan `WorkDir`) | +| `-packages` | `./...` | comma-separated package patterns, relative to `-workdir` | +| `-scan-models` | `true` | also emit definitions for `swagger:model` types | +| `-build-tags` | — | comma-separated go build tags to apply while loading | +| `-include` / `-exclude` | — | comma-separated patterns selecting which packages are scanned | +| `-include-tags` / `-exclude-tags` | — | comma-separated swagger tags selecting which operations are emitted | +| `-name-from-tags` | `json` | ordered 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 instead | +| `-name-concat-budget` | `0.65` | readability cutoff when deconflicting colliding definition names | + +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 · +`$ref` & composition · naming · docs & comments · types & extensions), and a +knob that only bites in combination says so — `PruneUnusedModels` shows +`(needs ScanModels)` until that one is on, and `EmitXGoType` shows +`(moot: SkipExtensions)` while extensions are suppressed. + +The value-typed options are flags rather than popup rows, since a checkbox list +cannot express them — see the table above. The one option with no route in at +all is `InputSpec` (overlay mode). + +## Layout + +``` +┌───────────────────────┬──────────────────────────────────────┐ +│ source tree │ spec · JSON │ +│ or the file viewer │ the generated document │ +├───────────────────────┴──────────────────────────────────────┤ +│ diagnostics │ +├──────────────────────────────────────────────────────────────┤ +│ status / help │ +└──────────────────────────────────────────────────────────────┘ +``` + +The left pane shows either the **source tree** or, once you open a file, the +**file viewer**. The viewer is read-only and navigable by default; `i` turns it +into an editor and `Esc` steps back out. Saving writes to disk, the watcher +notices, and the spec re-renders. + +Clicking a pane focuses it, and the mouse wheel scrolls whichever pane is under +the pointer — `Tab` is never required. + +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, and `h` (or `?`) opens the full +keymap grouped by pane. The table below mirrors that overlay. + +A rescan keeps you where you were: the cursor is restored to the same **node**, +not the same line number, so a definition appearing above what you are reading +does not slide you somewhere else. If that node is gone — you deleted the type — +the cursor falls back to its nearest surviving ancestor. + +## Keys + +### Anywhere + +| Key | Action | +|-----|--------| +| `h` / `?` | the key-bindings overlay (also advertised in the header) | +| `Tab` / `shift+Tab` | cycle focus forward / backward | +| click | focus the pane under the pointer | +| wheel | scroll the pane under the pointer | +| `c` | copy the focused pane's raw content to the clipboard | +| `r` | rescan now | +| `o` | scanner options popup (`space` toggles, `Esc`/`o` applies and rescans) | +| `ctrl+q` / `ctrl+c` | quit | + +### Spec pane + +| Key | Action | +|-----|--------| +| `↑` `↓` / `j` `k` | move the cursor | +| `PgUp` / `PgDn` | move it a page (the view never leaves the cursor behind) | +| `Home` / `End` | first / last line | +| `ctrl+j` / `ctrl+y` | render as JSON / YAML — keeps you on the same **node**, not the same line | +| `/` | search; `n` / `N` step through matches | +| `f` | toggle follow mode (spec drives, the source pane mirrors) | +| `F3` / `shift+F3` | next / previous **reference** to the node under the cursor | +| `Enter` | follow the `$ref` under the cursor to its definition | +| `Esc` | clear the search and the reference cycle | + +The spec pane has a line cursor, and everything above acts on **the node under +it**. Searching parks the cursor on the match, so `/` then `F3` or `Enter` +composes. + +### Source tree + +| Key | Action | +|-----|--------| +| `↑` `↓` / `j` `k` | move the selection | +| `PgUp` / `PgDn`, `Home` / `End` | move a page at a time, or jump to the ends | +| `←` / `→` | collapse / expand a directory | +| `Enter` | open a file (or expand/collapse a directory) | +| `g` | locate the selected file's first node in the spec | + +### File viewer (read-only) + +| Key | Action | +|-----|--------| +| `↑` `↓` / `j` `k` | move the navigation line | +| `PgUp` / `PgDn`, `Home` / `End` | move a page at a time, or jump to the ends | +| `f` | toggle follow mode (source drives, the spec mirrors) | +| `i` / `Enter` | start editing | +| `Esc` | back to the tree | + +The viewer shadows only these keys; every other binding (`/`, `o`, `r`, `g`, +`ctrl+j` / `ctrl+y`, `Tab`, `c`) still works while a file is open. + +### File editor + +| Key | Action | +|-----|--------| +| `ctrl+f` | jump from the cursor's line to the spec node it produced | +| `ctrl+s` | save (triggers a rescan) | +| `Esc` | back to the read-only viewer | + +`ctrl+f` rather than `f` because the editor owns plain `f` for typing. + +### Diagnostics pane + +| Key | Action | +|-----|--------| +| `↑` `↓` / `j` `k` | select a diagnostic | +| `PgUp` / `PgDn`, `Home` / `End` | select a page at a time, or jump to the ends | +| `Enter` | go to this diagnostic's source line and focus it | +| `f` | toggle follow mode (the selection drives, the source pane mirrors) | + +## Cross-reference navigation + +Two indexes, rebuilt on every render, meet at a JSON pointer: + +- the **spec index** maps each rendered line to the pointer of the node on it; +- the **source index** maps pointers to Go source positions, from codescan's + `OnProvenance` callback. + +### Follow mode (`f`) + +`f` turns on a persistent link between two panes. The pane you pressed it in is +the **driver** and keeps focus; the other **mirrors** it on every cursor move, +centring and highlighting the linked line. The two roles are styled differently +so it is always clear which pane leads. A `SPEC ▸ SOURCE` badge names the +direction and the resolved target. + +Follow works in three directions: spec → source, source → spec, and +diagnostic → source. `Esc`, a second `f`, changing focus, or starting to edit +all leave it. + +### References (`F3`, `Enter`) + +`F3` steps through the places the node under the cursor is referenced, wrapping; +`shift+F3` goes back. `Enter` follows a `$ref` to its definition. + +A cycle stays anchored to one definition while you keep pressing `F3`. Scroll +away and the next `F3` re-anchors on wherever you now are. + +### Syntax highlighting + +Both panes are coloured, by the same renderer and the same palette. + +The **spec pane** is coloured by key, string, number, keyword and punctuation. +The classification is free: the lexer that builds the line↔pointer index already +identifies every token, so highlighting is a third product of the same walk +rather than a second parse. + +The **source viewer** is coloured by `go/scanner` — the standard library's own +tokenizer, so no highlighting library is involved on either side. + +Comments there get three classes rather than one, because in a spec generator a +comment is not uniformly commentary: + +| Looks like | Reads as | Why | +|------------|----------|-----| +| `// swagger:model order` | a spec key | the annotation declares the thing; it is the input that produced the pane next to it | +| `// required: true` | a keyword | grammar the parser acts on — same class Go's own `type`/`func` get | +| `// the id of the order` | dimmed prose | freeform description | + +Only the keyword itself is lifted out, so `// required: true` reads as dim `//`, +coloured `required`, dim `: true` — the way `"required": true` reads on the spec +side. Recognition uses the parser's own keyword table, so aliases (`min` → +`minimum`, `min length` → `minLength`) and letter case come for free, and what +lights up is what the parser will act on. + +### Diagnostics at the site + +The scanner's own findings are drawn on the token they name, underlined in the +severity's colour — red for an error, amber for a warning, blue for a hint. The +diagnostics pane below tells you *what* and *where*; this tells you *which +token*, without leaving the line you are reading. + +Marks come from the last scan and are re-derived on every rescan, so they never +outlive the finding that produced them. Where codescan reports a position is +where the mark goes: a keyword-level diagnostic lands on the keyword, while +`swagger:type: "array" is deprecated` lands on the **declaration**, because that +is where the builder reports it. The mark says "there is a finding about this", +not "this is deprecated". + +### Keyword scope + +Keyword highlighting is scoped to files that declare at least one annotation. +`name`, `in` and `example` are ordinary English words, and lighting them up in a +file the scanner never reads would claim something untrue. Within such a file +the scope is the whole file, not the comment block: a field's constraints live +in the field's doc comment while the `swagger:model` that gives them meaning +sits on the enclosing type. + +Precedence on a line is **cursor, then search match, then syntax**. The first two +answer questions you asked, so they take the whole line instead of competing +with colour for it. + +### The gutter + +Both panes mark which lines actually lead somewhere, so you can see what is +navigable without probing for it: + +| Marker | In the spec pane | In the source viewer | +|--------|------------------|----------------------| +| `•` | this node has a source position of its **own**, so following it lands exactly there | this 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. External `$ref`s are not marked either, because +`Enter` cannot follow them. + +The gutter column only appears when there is something to mark. + +## Honest limits + +These are known and deliberate; the TUI says so rather than guessing. + +- **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, for instance); 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.** References are found by + scanning the rendered document. Local `#/…` refs are followable; a ref into + another file or a URL is reported as external rather than chased. Ref-to-ref + chains and `$ref` nested in `allOf` are not unwound. +- **Keyword highlighting cannot know which declarations are scanned.** It knows + the file is annotated and the word is in the grammar's table; it does not know + whether codescan visits that particular type. A keyword-shaped line in an + unrelated comment of an annotated file still lights up. Knowing better needs + the AST, and the AST needs a file that parses — which the buffer you are + editing may not. +- **The editor normalises whitespace.** `bubbles/textarea` rewrites tabs as four + spaces when a file is loaded into it, and treats a lone CR as a line break, so + files are converted to LF on the way in. Neither has an exported knob. The + viewer, the highlighter and the cross-ref line numbers all agree with each + other because they all read 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; + the VIM/VS-Code integration is the real answer. +- **Only the read-only source viewer is highlighted.** `bubbles/textarea` owns + its own rendering and emits the buffer verbatim, so edit mode shows plain + text; `Esc` returns to the coloured viewer, re-tokenizing what you typed. +- **`shift+F3` is terminal-dependent.** bubbletea v1's key type carries no Shift + modifier, and the xterm family reports shift+F3 as F15. Terminals that send + something else have no previous-reference key; `F3` still wraps around. + +## Development + +```sh +go test ./... # from cmd/genspec-tui +go test work ./... # from the repo root: every module at once +golangci-lint run --new-from-rev master +``` + +The TUI has no CI workflow of its own: `go.work` lists it, so the shared +monorepo workflow lints and tests it alongside the library, across the +`{ubuntu, macos, windows} × {stable, oldstable}` matrix. + +The package layout under `internal/ux`: + +| Package | Contents | +|---------|----------| +| `ux` | the root bubbletea `Model`: key dispatch, layout, scan wiring, cross-ref navigation | +| `ux/panels` | the four panes — `Tree`, `FileView`, `Spec`, `Diagnostics` | +| `ux/index` | `SpecIndex` (line ↔ pointer), `RefIndex` (`$ref` sites), `SourceIndex` (pointer ↔ source position) | +| `ux/key` | `tea.KeyMsg` → a small named-binding enum | +| `ux/theme` | the shared lipgloss styles | +| `ux/gadgets` | clipboard support | + +The scanner writes nothing to stdout or stderr: diagnostics arrive through +codescan's `OnDiagnostic` callback, and `main` discards the standard logger, so +nothing paints over the alt-screen. + +[codescan]: https://github.com/go-openapi/codescan diff --git a/cmd/genspec-tui/go.mod b/cmd/genspec-tui/go.mod new file mode 100644 index 00000000..c2cd20ce --- /dev/null +++ b/cmd/genspec-tui/go.mod @@ -0,0 +1,55 @@ +module github.com/go-openapi/codescan/cmd/genspec-tui + +go 1.25.8 + +require ( + github.com/charmbracelet/bubbles v1.0.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/fsnotify/fsnotify v1.10.1 + github.com/go-openapi/codescan v0.34.0 + github.com/go-openapi/core/json v0.0.2 + github.com/go-openapi/core/json/lexers/yaml-lexer v0.0.2 + github.com/go-openapi/testify/v2 v2.6.0 + github.com/muesli/termenv v0.16.0 + go.yaml.in/yaml/v3 v3.0.5 +) + +require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.4.3 // indirect + github.com/charmbracelet/x/ansi v0.11.7 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/go-openapi/jsonpointer v1.0.0 // indirect + github.com/go-openapi/jsonreference v1.0.0 // indirect + github.com/go-openapi/spec v0.22.9 // indirect + github.com/go-openapi/swag/conv v0.28.0 // indirect + github.com/go-openapi/swag/jsonutils v0.28.0 // indirect + github.com/go-openapi/swag/loading v0.28.0 // indirect + github.com/go-openapi/swag/mangling v0.28.0 // indirect + github.com/go-openapi/swag/pools v0.28.0 // indirect + github.com/go-openapi/swag/stringutils v0.28.0 // indirect + github.com/go-openapi/swag/typeutils v0.28.0 // indirect + github.com/go-openapi/swag/yamlutils v0.28.0 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect + github.com/lucasb-eyer/go-colorful v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.27 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/mod v0.38.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect + golang.org/x/tools v0.48.0 // indirect +) + +replace github.com/go-openapi/codescan => ../.. diff --git a/cmd/genspec-tui/go.sum b/cmd/genspec-tui/go.sum new file mode 100644 index 00000000..39b8e154 --- /dev/null +++ b/cmd/genspec-tui/go.sum @@ -0,0 +1,99 @@ +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= +github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= +github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= +github.com/go-openapi/core/json v0.0.2 h1:RACr1Kjs6U8Rzpu0JLnJ2tw5TZ86+5ROXFPNQg1L9I0= +github.com/go-openapi/core/json v0.0.2/go.mod h1:vEcP/Wkw1ImzIAmGt7lmY+dJ8Ilf0TtvV8vPe8HtVA4= +github.com/go-openapi/core/json/lexers/yaml-lexer v0.0.2 h1:aJC6mspwBIPzxJoduTagX416RvVRMZL6yygngyITHoM= +github.com/go-openapi/core/json/lexers/yaml-lexer v0.0.2/go.mod h1:p4x5CYKYZecVZLy22fOMap8EGW5Rkb4yPxpIzWuCYr4= +github.com/go-openapi/jsonpointer v1.0.0 h1:kR9tHqY0CtZaOPVFm622dPVNhrvYpwr4uCxgL3h1H8s= +github.com/go-openapi/jsonpointer v1.0.0/go.mod h1:Z3rw7dWu1p9IgitXCFamSlA5lmDiklEB6vkaxcNZW5Y= +github.com/go-openapi/jsonreference v1.0.0 h1:jlmTr6torcd1YgDQvSfNmRtKzYDO4FGBkrAdlAVWnpY= +github.com/go-openapi/jsonreference v1.0.0/go.mod h1:jtwdyGbJk0Xhe5Y+rwtglQP6Sb1WZST4rT32LWB+sv0= +github.com/go-openapi/spec v0.22.9 h1:/vKIFDcGKp0ktZWGbym/tJEWbk6/XOEmAVU0kqKMH+w= +github.com/go-openapi/spec v0.22.9/go.mod h1:b/mNUYIOQOyIiUzUzXEE8xzyZqf93KvM9hQGP91yfl0= +github.com/go-openapi/swag/conv v0.28.0 h1:GtqqbyFe7vR5Y7ehxG9W6/OvrSFdf1OLeTGp40TqxH8= +github.com/go-openapi/swag/conv v0.28.0/go.mod h1:mbUE+mzctnhxi864m0Q07SpN8OowD9JhxmxuYvZZD/k= +github.com/go-openapi/swag/jsonutils v0.28.0 h1:YIch6FwO7RXzeAnbO8Tu7dWBZeUEH+4nA0HXltVTnv4= +github.com/go-openapi/swag/jsonutils v0.28.0/go.mod h1:CYM3WlTUcagR2ZoHdz54di/cbBqt82tuxuXgAjxw+mg= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.28.0 h1:qV+VVUAx5Oro8WjVWpZeql7YReTKhT4smR4zhcOQZr0= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.28.0/go.mod h1:mofwUWx70wvskwESqRJ//k/9kURmCgyJl5m5Ppoh5kY= +github.com/go-openapi/swag/loading v0.28.0 h1:td8QZdZC9MIYGGSnSPKShKiK22I2tU5UQvuUhIBPRLU= +github.com/go-openapi/swag/loading v0.28.0/go.mod h1:rXB0QiQX5mMveXEA7ouM4KiiM9jVJe4K6BVbwhD1M4k= +github.com/go-openapi/swag/mangling v0.28.0 h1:pH8eyeNO9SLYsTMWJrurnNfKmDa28XrlA+HePVD53VM= +github.com/go-openapi/swag/mangling v0.28.0/go.mod h1:jtBE2+V+3pILxOR7Vgce+Cwp6A2PgZbvVqfNntbVs0w= +github.com/go-openapi/swag/pools v0.28.0 h1:HPMZWSAfce3rdVTFcjFiCIBtDg9h4x2QlRrHipwhxeU= +github.com/go-openapi/swag/pools v0.28.0/go.mod h1:kVQefhSK5RWuRe7BXsL8htgBPAMpN7HDGpGEknqugeE= +github.com/go-openapi/swag/stringutils v0.28.0 h1:ixsc9iYgDPubHL/8nSkbnryEHpD2VRlBMLKpQyPXcDU= +github.com/go-openapi/swag/stringutils v0.28.0/go.mod h1:lzRN95CxXmA03XcDWHLOb6nOMcxCqR5rGY0lOgsfRoM= +github.com/go-openapi/swag/typeutils v0.28.0 h1:nRBKSBXjDgf01VDPB3fWeD9nQuhCOVeIYAkUx2tbkyY= +github.com/go-openapi/swag/typeutils v0.28.0/go.mod h1:Srm0xFNRZ1Y+vCxJclo5qzx8aj+1pAKda/YfFPrG0dQ= +github.com/go-openapi/swag/yamlutils v0.28.0 h1:TV3JXH6DS46KUroDtMLAYHGkdWf5VDq3wVWFirmzROY= +github.com/go-openapi/swag/yamlutils v0.28.0/go.mod h1:x0q/yndZHEgk9Rx3DyDqzFUmHy55KTvIZldvF2dTJXs= +github.com/go-openapi/testify/enable/yaml/v2 v2.6.0 h1:gGHwAJ0R/5jU8BEGDbfRNR3hL68dAVi84WuOApp29B0= +github.com/go-openapi/testify/enable/yaml/v2 v2.6.0/go.mod h1:tY+St1SGq4NFl0QIqdTY4aEdbChAHxhyB77XQi9iJCo= +github.com/go-openapi/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAgUEu3ug= +github.com/go-openapi/testify/v2 v2.6.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= +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.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= +github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.27 h1:Feg/Oou5zI/wnpgDF6omIU0OokC9GxLC/WRknhVlIR0= +github.com/mattn/go-runewidth v0.0.27/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= diff --git a/cmd/genspec-tui/internal/ux/diagnostics_render.go b/cmd/genspec-tui/internal/ux/diagnostics_render.go new file mode 100644 index 00000000..116f32bb --- /dev/null +++ b/cmd/genspec-tui/internal/ux/diagnostics_render.go @@ -0,0 +1,126 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package ux + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/charmbracelet/lipgloss" + + "github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux/theme" + "github.com/go-openapi/codescan/internal/parsers/grammar" +) + +// renderDiagnostics composes the diagnostics-pane body for one scan outcome and +// reports the 0-based content line of the selected diagnostic (-1 when none). +// +// A hard error from codescan.Run is shown first — it aborts the whole spec, so +// it dwarfs everything else. Soft diagnostics follow a one-line severity tally, +// one per row in source order, colored by severity; the selected row gets the +// whole-line highlight (for diagnostic→source navigation). Paths are trimmed to +// the work dir to keep rows short. An empty, error-free scan shows the rest +// state. The selected line is counted as the body is built, so it stays correct +// even when a diagnostic message spans multiple lines. +func renderDiagnostics(workdir string, scanErr error, diags []grammar.Diagnostic, selected int, focused bool) (string, int) { + var b strings.Builder + selectedLine := -1 + + if scanErr != nil { + b.WriteString(theme.SevError().Render("scan failed: ") + scanErr.Error()) + if len(diags) == 0 { + return b.String(), -1 + } + b.WriteString("\n\n") + } + + if len(diags) == 0 { + return "(no diagnostics)", -1 + } + + b.WriteString(theme.Status().Render(diagnosticTally(diags))) + for i, d := range diags { + b.WriteString("\n") + row := formatDiagnostic(workdir, d) + if i == selected { + selectedLine = strings.Count(b.String(), "\n") // 0-based line of this row + // Same rule as the spec pane and the source viewer (§6.5): the + // strong bar means "you are driving this", the muted tint means + // "this is where you were". Two strong bars on screen at once make + // it ambiguous which pane a keypress will reach. + if focused { + row = theme.Selected().Render(row) + } else { + row = theme.Follower().Render(row) + } + } + b.WriteString(row) + } + return b.String(), selectedLine +} + +// diagnosticTally summarizes a diagnostic slice as "N diagnostics (E errors, W +// warnings, H hints)", omitting any zero buckets. +func diagnosticTally(diags []grammar.Diagnostic) string { + var e, w, h int + for _, d := range diags { + switch d.Severity { + case grammar.SeverityError: + e++ + case grammar.SeverityWarning: + w++ + default: + h++ + } + } + + var parts []string + for _, p := range []struct { + n int + one string + }{{e, "error"}, {w, "warning"}, {h, "hint"}} { + if p.n > 0 { + parts = append(parts, fmt.Sprintf("%d %s%s", p.n, p.one, plural(p.n))) + } + } + + noun := "diagnostic" + plural(len(diags)) + if len(parts) == 0 { + return fmt.Sprintf("%d %s", len(diags), noun) + } + return fmt.Sprintf("%d %s (%s)", len(diags), noun, strings.Join(parts, ", ")) +} + +// formatDiagnostic renders one diagnostic as "path:line:col severity: message +// [code]", with the severity label colored and the path made relative to +// workdir when it sits inside the scanned tree. +func formatDiagnostic(workdir string, d grammar.Diagnostic) string { + loc := d.Pos.String() // absolute "file:line:col" (or "-" when unknown) + if rel, err := filepath.Rel(workdir, d.Pos.Filename); err == nil && !strings.HasPrefix(rel, "..") { + loc = fmt.Sprintf("%s:%d:%d", rel, d.Pos.Line, d.Pos.Column) + } + sev := severityStyle(d.Severity).Render(d.Severity.String()) + return fmt.Sprintf("%s %s: %s [%s]", loc, sev, d.Message, d.Code) +} + +// severityStyle maps a grammar.Severity to its diagnostics-pane style. +func severityStyle(s grammar.Severity) lipgloss.Style { + switch s { + case grammar.SeverityError: + return theme.SevError() + case grammar.SeverityWarning: + return theme.SevWarn() + default: + return theme.SevHint() + } +} + +// plural returns "s" unless n is exactly 1. +func plural(n int) string { + if n == 1 { + return "" + } + return "s" +} diff --git a/cmd/genspec-tui/internal/ux/diagnostics_render_test.go b/cmd/genspec-tui/internal/ux/diagnostics_render_test.go new file mode 100644 index 00000000..b4171316 --- /dev/null +++ b/cmd/genspec-tui/internal/ux/diagnostics_render_test.go @@ -0,0 +1,139 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package ux + +import ( + "go/token" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/go-openapi/codescan" + "github.com/go-openapi/codescan/internal/parsers/grammar" +) + +// badMaximumFixture is the minimal diagnostic trigger: a swagger:model whose +// field carries a non-numeric `maximum:`. The parser drops the keyword from the +// spec and emits grammar.CodeInvalidNumber — exactly the soft-diagnostic shape +// the pane is built to surface. Kept inline so the test owns its input and the +// lean TUI module needs no root test-fixture dependency. +const badMaximumFixture = `package diagfixture + +// BadMaximum has an invalid maximum: value. +// +// swagger:model BadMaximum +type BadMaximum struct { + // Count holds an arbitrary count. + // + // maximum: notanumber + Count int ` + "`json:\"count\"`" + ` +} +` + +// TestDoScanCollectsDiagnostics is the end-to-end wiring proof: a malformed +// numeric validation surfaces the parser's CodeInvalidNumber through +// Options.OnDiagnostic into scanResultMsg.diags, without failing the scan +// (diagnostics never abort the build). +func TestDoScanCollectsDiagnostics(t *testing.T) { + dir := writeModule(t, map[string]string{ + "go.mod": "module diagfixture\n\ngo 1.25\n", + "types.go": badMaximumFixture, + }) + + res := doScan(codescan.Options{ + WorkDir: dir, + Packages: []string{"."}, + ScanModels: true, + }) + + if res.err != nil { + t.Fatalf("scan should not hard-fail on soft diagnostics: %v", res.err) + } + if len(res.diags) == 0 { + t.Fatal("expected at least one diagnostic from the malformed fixture") + } + + found := false + for _, d := range res.diags { + if d.Code == grammar.CodeInvalidNumber { + found = true + break + } + } + if !found { + t.Errorf("expected a %s diagnostic; got %v", grammar.CodeInvalidNumber, codes(res.diags)) + } +} + +// TestRenderDiagnostics checks the three render states: clean, hard-error, and +// a soft-diagnostic list with a severity tally and relative paths. +func TestRenderDiagnostics(t *testing.T) { + t.Run("clean", func(t *testing.T) { + if got, _ := renderDiagnostics("/work", nil, nil, 0, true); got != "(no diagnostics)" { + t.Errorf("clean scan: got %q", got) + } + }) + + t.Run("hard error", func(t *testing.T) { + got, _ := renderDiagnostics("/work", codescan.ErrCodeScan, nil, 0, true) + if !strings.Contains(got, "scan failed") || !strings.Contains(got, codescan.ErrCodeScan.Error()) { + t.Errorf("hard error not surfaced: %q", got) + } + }) + + t.Run("soft diagnostics", func(t *testing.T) { + diags := []grammar.Diagnostic{ + grammar.Errorf(pos("/work/models/a.go", 12, 3), grammar.CodeInvalidNumber, "bad maximum"), + grammar.Warnf(pos("/work/models/a.go", 20, 5), grammar.CodeAmbiguousEmbed, "ambiguous"), + } + got, _ := renderDiagnostics("/work", nil, diags, 0, true) + + for _, want := range []string{ + "2 diagnostics (1 error, 1 warning)", + filepath.FromSlash("models/a.go") + ":12:3", // trimmed to workdir, native separators + "bad maximum", + string(grammar.CodeInvalidNumber), + "error", + "warning", + } { + if !strings.Contains(got, want) { + t.Errorf("rendered diagnostics missing %q in:\n%s", want, got) + } + } + if strings.Contains(got, filepath.FromSlash("/work/models")) { + t.Errorf("absolute path leaked into rendered diagnostics:\n%s", got) + } + }) +} + +// writeModule materializes files (relative path → content) under a fresh temp +// dir and returns it. codescan scans it as a standalone module (it forces +// GOWORK=off), so no go.sum or workspace entry is needed for a stdlib-only tree. +func writeModule(t *testing.T, files map[string]string) string { + t.Helper() + dir := t.TempDir() + for rel, content := range files { + path := filepath.Join(dir, rel) + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + t.Fatalf("mkdir for %s: %v", rel, err) + } + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("write %s: %v", rel, err) + } + } + return dir +} + +func pos(file string, line, col int) token.Position { + return token.Position{Filename: file, Line: line, Column: col} +} + +func codes(diags []grammar.Diagnostic) []grammar.Code { + out := make([]grammar.Code, 0, len(diags)) + for _, d := range diags { + out = append(out, d.Code) + } + return out +} diff --git a/cmd/genspec-tui/internal/ux/gadgets/clipboard.go b/cmd/genspec-tui/internal/ux/gadgets/clipboard.go new file mode 100644 index 00000000..9540f53d --- /dev/null +++ b/cmd/genspec-tui/internal/ux/gadgets/clipboard.go @@ -0,0 +1,91 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package gadgets holds small, self-contained TUI helpers. The clipboard +// helper is ported from fredbi/git-janitor: it copies text reliably across +// terminals by trying real clipboard tools first (which report success), then +// falling back to OSC 52 escape sequences (which work over SSH and in modern +// terminals without any external tool), with tmux passthrough wrapping. +package gadgets + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "os" + "os/exec" + "strings" +) + +// ErrNoClipboardTool reports that no command-line clipboard tool was found. +// Static so a caller can tell "nothing installed" from "the tool failed" and +// decide whether the OSC 52 fallback is worth mentioning. +var ErrNoClipboardTool = errors.New("no clipboard tool available (tried xclip, xsel, wl-copy)") + +// CopyToClipboard copies text to the system clipboard. +// +// It tries command-line tools first — they give reliable feedback (OSC 52 is +// fire-and-forget, so we can't tell whether the terminal honored it) — then +// falls back to OSC 52. +func CopyToClipboard(ctx context.Context, text string) error { + if err := clipboardViaTool(ctx, text); err == nil { + return nil + } + + return osc52Copy(text) +} + +// clipboardViaTool tries xclip, xsel, then wl-copy, in order. +func clipboardViaTool(ctx context.Context, text string) error { + tools := []struct { + name string + args []string + }{ + {"xclip", []string{"-selection", "clipboard"}}, + {"xsel", []string{"--clipboard", "--input"}}, + {"wl-copy", nil}, + } + + for _, t := range tools { + path, err := exec.LookPath(t.name) + if err != nil { + continue + } + + cmd := exec.CommandContext(ctx, path, t.args...) + cmd.Stdin = strings.NewReader(text) + + if err := cmd.Run(); err == nil { + return nil + } + } + + return ErrNoClipboardTool +} + +// osc52Copy writes an OSC 52 escape sequence to stderr, instructing the +// terminal emulator to copy text to the system clipboard. Works on kitty, +// alacritty, wezterm, iTerm2, Windows Terminal, foot, etc.; not on +// gnome-terminal or some older terminals. Stderr is used so the sequence +// bypasses bubbletea's stdout render buffer. +func osc52Copy(text string) error { + b64 := base64.StdEncoding.EncodeToString([]byte(text)) + + // OSC 52 ; c ; BEL + seq := fmt.Sprintf("\x1b]52;c;%s\x07", b64) + + // Detect tmux and wrap in passthrough DCS. + if isTmux() { + seq = fmt.Sprintf("\x1bPtmux;\x1b%s\x1b\\", seq) + } + + _, err := fmt.Fprint(os.Stderr, seq) + + return err +} + +func isTmux() bool { + return strings.HasPrefix(os.Getenv("TERM_PROGRAM"), "tmux") || + os.Getenv("TMUX") != "" +} diff --git a/cmd/genspec-tui/internal/ux/help.go b/cmd/genspec-tui/internal/ux/help.go new file mode 100644 index 00000000..88d57425 --- /dev/null +++ b/cmd/genspec-tui/internal/ux/help.go @@ -0,0 +1,145 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package ux + +import ( + "strings" + + "github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux/theme" +) + +// helpEntry is one row of the help overlay: the key(s), and what they do. +type helpEntry struct { + keys string + action string +} + +// helpSection groups bindings by the context they apply in. 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 a flat list would be +// actively misleading. +type helpSection struct { + title string + entries []helpEntry +} + +// helpSections is the whole keymap, in the order the overlay shows it. It is the +// single source of truth for the overlay; the README table mirrors it by hand. +// +//nolint:gochecknoglobals // static content, read-only +var helpSections = []helpSection{ + {"anywhere", []helpEntry{ + {"h ?", "this help"}, + {"tab shift+tab", "cycle focus"}, + {"click", "focus the pane under the pointer"}, + {"wheel", "move the cursor in the pane under the pointer"}, + {"c", "copy the focused pane to the clipboard"}, + {"r", "rescan now"}, + {"o", "scanner options"}, + {"ctrl+q ctrl+c", "quit"}, + }}, + {"spec pane", []helpEntry{ + {"↑ ↓ j k", "move the cursor"}, + {"pgup pgdn", "move it a page"}, + {"home end", "first / last line"}, + {"ctrl+j ctrl+y", "render as JSON / YAML (keeps the node)"}, + {"/", "search"}, + {"n N", "next / previous match"}, + {"f", "follow mode → source"}, + {"F3 shift+F3", "next / previous reference to this node"}, + {"enter", "go to the definition of the $ref here"}, + {"esc", "clear search and the reference cycle"}, + }}, + {"source tree", []helpEntry{ + {"↑ ↓ j k", "move the selection"}, + {"pgup pgdn", "move it a page"}, + {"home end", "first / last entry"}, + {"← →", "collapse / expand a directory"}, + {"enter", "open a file / expand a directory"}, + {"g", "locate this file's first node in the spec"}, + }}, + {"file viewer", []helpEntry{ + {"↑ ↓ j k", "move the navigation line"}, + {"pgup pgdn", "move it a page"}, + {"home end", "first / last line"}, + {"f", "follow mode → spec"}, + {"i enter", "start editing"}, + {"esc", "back to the tree"}, + }}, + {"file editor", []helpEntry{ + {"ctrl+f", "jump to the spec node this line produced"}, + {"ctrl+s", "save (triggers a rescan)"}, + {"esc", "back to the viewer"}, + }}, + {"diagnostics", []helpEntry{ + {"↑ ↓ j k", "select a diagnostic"}, + {"pgup pgdn", "select a page at a time"}, + {"home end", "first / last diagnostic"}, + {"enter", "go to this diagnostic's source line"}, + {"f", "follow mode → source"}, + }}, + {"options popup", []helpEntry{ + {"↑ ↓ j k", "move"}, + {"pgup pgdn home end", "move faster"}, + {"space", "toggle"}, + {"esc o", "apply and close"}, + }}, +} + +// helpLines renders the overlay body: a key column wide enough for every entry, +// then the action, with a blank line between sections. +func helpLines() []string { + keyW := 0 + for _, sec := range helpSections { + for _, e := range sec.entries { + keyW = max(keyW, len([]rune(e.keys))) + } + } + + var lines []string + for i, sec := range helpSections { + if i > 0 { + lines = append(lines, "") + } + lines = append(lines, theme.Accent().Render(sec.title)) + for _, e := range sec.entries { + pad := strings.Repeat(" ", keyW-len([]rune(e.keys))) + lines = append(lines, " "+e.keys+pad+" "+theme.Status().Render(e.action)) + } + } + + return lines +} + +// helpVisibleRows is how many body rows fit between the modal's chrome. +func (m *Model) helpVisibleRows() int { + const chrome = 10 // border 2 + padding 2 + title 2 + footer 2, with slack + + return max(m.height-chrome, 3) +} + +// helpView renders the help modal, scrolled to m.helpScroll. +func (m *Model) helpView() string { + lines := helpLines() + visible := m.helpVisibleRows() + + var b strings.Builder + b.WriteString(theme.Accent().Render("Key bindings")) + b.WriteString("\n\n") + + if len(lines) > visible { + top := clampInt(m.helpScroll, 0, len(lines)-visible) + lines = lines[top : top+visible] + } + b.WriteString(strings.Join(lines, "\n")) + b.WriteString("\n\n") + b.WriteString(theme.Status().Render("↑↓/jk: scroll · esc/h/?: close")) + + return theme.Modal().Render(b.String()) +} + +// scrollHelp moves the help window, clamped so it can never scroll past the end. +func (m *Model) scrollHelp(delta int) { + m.helpScroll = clampInt(m.helpScroll+delta, 0, max(len(helpLines())-m.helpVisibleRows(), 0)) +} diff --git a/cmd/genspec-tui/internal/ux/help_test.go b/cmd/genspec-tui/internal/ux/help_test.go new file mode 100644 index 00000000..e84a0762 --- /dev/null +++ b/cmd/genspec-tui/internal/ux/help_test.go @@ -0,0 +1,218 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package ux + +import ( + "os" + "path/filepath" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/go-openapi/codescan" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +func newHelpModel(t *testing.T) *Model { + t.Helper() + m := New(codescan.Options{WorkDir: t.TempDir(), Packages: []string{"./..."}}) + t.Cleanup(m.Close) + m.width, m.height = 100, 40 + m.ready = true + + return m +} + +func keyRune(r rune) tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}} } + +// writeTempGo puts a Go file on disk and returns its path. +func writeTempGo(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "x.go") + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + + return path +} + +func TestHelp_OpensOnHAndQuestionMark(t *testing.T) { + for _, k := range []rune{'h', '?'} { + m := newHelpModel(t) + + _, _ = m.handleKey(keyRune(k)) + + assert.True(t, m.helpOpen, "%q opens the help", k) + assert.Contains(t, stripANSI(m.View()), "Key bindings") + } +} + +// The overlay is opened to look something up, not resumed, so it always starts +// at the top. +func TestHelp_OpensAtTheTop(t *testing.T) { + m := newHelpModel(t) + m.helpOpen = true + m.scrollHelp(+5) + require.Positive(t, m.helpScroll) + + m.helpOpen = false + _, _ = m.handleKey(keyRune('h')) + + assert.Zero(t, m.helpScroll) +} + +func TestHelp_Closes(t *testing.T) { + for _, msg := range []tea.KeyMsg{ + {Type: tea.KeyEsc}, + keyRune('h'), + keyRune('?'), + {Type: tea.KeyEnter}, + } { + m := newHelpModel(t) + _, _ = m.handleKey(keyRune('h')) + require.True(t, m.helpOpen) + + _, _ = m.handleKey(msg) + + assert.False(t, m.helpOpen, "%v closes the help", msg) + } +} + +// While the overlay covers the UI, acting on a key whose effect the user cannot +// see would be worse than ignoring it. +func TestHelp_SwallowsOtherKeys(t *testing.T) { + m := newHelpModel(t) + _, _ = m.handleKey(keyRune('h')) + + for _, msg := range []tea.KeyMsg{keyRune('r'), keyRune('o'), keyRune('/'), {Type: tea.KeyF3}} { + _, _ = m.handleKey(msg) + } + + assert.True(t, m.helpOpen, "still open") + assert.False(t, m.scanning, "r did not start a scan") + assert.False(t, m.optionsOpen, "o did not open the options") + assert.False(t, m.searching, "/ did not open search") +} + +// `h` is an ordinary character in the editor; opening help there would make the +// buffer unusable. +func TestHelp_DoesNotHijackTheEditor(t *testing.T) { + m := newHelpModel(t) + m.loadFileQuietly(writeTempGo(t, "package p\n")) + m.focused, m.leftMode = paneTree, modeView + _ = m.fileView.StartEdit() + require.True(t, m.fileView.Editing()) + + _, _ = m.handleKey(keyRune('h')) + + assert.False(t, m.helpOpen, "the editor keeps plain h for typing") + assert.Contains(t, m.fileView.Value(), "h", "and the character reached the buffer") +} + +// ...but the read-only viewer passes it through, like the other global keys. +func TestHelp_OpensFromTheReadOnlyViewer(t *testing.T) { + m := newHelpModel(t) + m.loadFileQuietly(writeTempGo(t, "package p\n")) + m.focused, m.leftMode = paneTree, modeView + require.False(t, m.fileView.Editing()) + + _, _ = m.handleKey(keyRune('h')) + + assert.True(t, m.helpOpen) +} + +func TestHelp_Scrolls(t *testing.T) { + m := newHelpModel(t) + m.height = 20 // fewer visible rows than the keymap has + _, _ = m.handleKey(keyRune('h')) + require.Greater(t, len(helpLines()), m.helpVisibleRows(), "precondition: the keymap overflows") + + _, _ = m.handleKey(tea.KeyMsg{Type: tea.KeyDown}) + assert.Equal(t, 1, m.helpScroll) + + // Clamped at the top... + for range 10 { + _, _ = m.handleKey(tea.KeyMsg{Type: tea.KeyUp}) + } + assert.Zero(t, m.helpScroll) + + // ...and at the bottom. + maxScroll := len(helpLines()) - m.helpVisibleRows() + for range len(helpLines()) + 5 { + _, _ = m.handleKey(tea.KeyMsg{Type: tea.KeyDown}) + } + assert.Equal(t, maxScroll, m.helpScroll) + + _, _ = m.handleKey(tea.KeyMsg{Type: tea.KeyHome}) + assert.Zero(t, m.helpScroll) + _, _ = m.handleKey(tea.KeyMsg{Type: tea.KeyEnd}) + assert.Equal(t, maxScroll, m.helpScroll) +} + +// A keymap that does not fit and cannot scroll would silently hide bindings. +func TestHelp_ShortTerminalStillReachesTheEnd(t *testing.T) { + m := newHelpModel(t) + m.height = 14 + _, _ = m.handleKey(keyRune('h')) + + for range len(helpLines()) { + _, _ = m.handleKey(tea.KeyMsg{Type: tea.KeyDown}) + } + + // The last ENTRY, not the last section title: on a very short window the + // title itself scrolls off while its rows are still on screen, and it is the + // rows that must stay reachable. + lastSection := helpSections[len(helpSections)-1] + lastEntry := lastSection.entries[len(lastSection.entries)-1] + assert.Contains(t, stripANSI(m.helpView()), lastEntry.action, + "the end of the keymap must be reachable by scrolling") +} + +func TestHelp_BannerIsInTheHeader(t *testing.T) { + m := newHelpModel(t) + m.cfg.WorkDir = "/a/very/long/path/that/would/otherwise/crowd/the/header/line/out" + + header := stripANSI(m.headerLine()) + + assert.Contains(t, header, "h: help", + "the banner must survive a long work dir — it is what reveals every other key") + assert.Less(t, strings.Index(header, "h: help"), strings.Index(header, "JSON"), + "and sit early in the line, where nothing can push it off") +} + +func TestHelp_ContentIsWellFormed(t *testing.T) { + require.NotEmpty(t, helpSections) + + titles := make(map[string]bool, len(helpSections)) + for _, sec := range helpSections { + assert.NotEmpty(t, sec.title) + assert.False(t, titles[sec.title], "duplicate section %q", sec.title) + titles[sec.title] = true + assert.NotEmpty(t, sec.entries, "section %q is empty", sec.title) + + keys := make(map[string]bool, len(sec.entries)) + for _, e := range sec.entries { + assert.NotEmpty(t, e.keys, "section %q has an entry with no keys", sec.title) + assert.NotEmpty(t, e.action, "entry %q in %q has no action", e.keys, sec.title) + assert.False(t, keys[e.keys], "duplicate entry %q in section %q", e.keys, sec.title) + keys[e.keys] = true + } + } +} + +// The overlay is the only in-app record of the keymap, so a binding that gets +// dispatched but never listed is invisible. This is a coarse guard — it cannot +// prove completeness — but it fails if a documented key is dropped. +func TestHelp_ListsTheDispatchedBindings(t *testing.T) { + body := stripANSI(strings.Join(helpLines(), "\n")) + + for _, k := range []string{ + "h", "?", "tab", "shift+tab", "c", "r", "o", "ctrl+q", + "j k", "pgup", "home", "ctrl+j", "ctrl+y", "/", "n N", "f", + "F3", "shift+F3", "enter", "esc", + "g", "i", "ctrl+f", "ctrl+s", "space", + } { + assert.Contains(t, body, k, "binding %q is dispatched but not in the help", k) + } +} diff --git a/cmd/genspec-tui/internal/ux/index/diagmarks.go b/cmd/genspec-tui/internal/ux/index/diagmarks.go new file mode 100644 index 00000000..f04e7908 --- /dev/null +++ b/cmd/genspec-tui/internal/ux/index/diagmarks.go @@ -0,0 +1,69 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package index + +import ( + "slices" + + "github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux/theme" +) + +// DiagMark is one diagnostic located in the DISPLAYED text: a 0-based line and +// a 1-based rune column, already translated out of the file coordinates the +// scanner reports in. Kind is the severity class to paint. +type DiagMark struct { + Line, Col int + Kind theme.SyntaxKind +} + +// MarkDiagnostics overlays diagnostic marks onto lexical spans, returning a new +// map and leaving the input untouched — the lexical spans are rebuilt only when +// the buffer changes, while the marks change on every rescan. +// +// A diagnostic wins over the token it lands on. The scanner's opinion is why +// the pane is open, so it takes the run rather than tinting around it. +// +// Nil spans stay nil: a file with no lexical runs is one we do not tokenize +// (not Go), and inventing runs for it would colour text nobody classified. +func MarkDiagnostics(spans map[int][]theme.Span, marks []DiagMark) map[int][]theme.Span { + if spans == nil || len(marks) == 0 { + return spans + } + + out := make(map[int][]theme.Span, len(spans)) + for line, runs := range spans { + out[line] = slices.Clone(runs) + } + + for _, mark := range marks { + if mark.Line < 0 || mark.Col < 1 { + continue + } + out[mark.Line] = markRun(out[mark.Line], mark) + } + + return out +} + +// markRun restyles the run that BEGINS at the mark's column, or opens one there +// when the mark falls inside a run. +// +// The exact hit is the common case rather than a lucky one: a diagnostic and a +// lexical run both address a token, so they agree on where it starts — a +// keyword-level diagnostic lands on the keyword's run, a declaration-level one +// on the identifier's. Opening a run mid-token is the honest fallback: it paints +// from the reported column to the next run, which over-reaches rather than +// pointing somewhere false. +func markRun(runs []theme.Span, mark DiagMark) []theme.Span { + at, found := slices.BinarySearchFunc(runs, mark.Col, func(s theme.Span, col int) int { + return s.Col - col + }) + if found { + runs[at].Kind = mark.Kind + + return runs + } + + return slices.Insert(runs, at, theme.Span{Col: mark.Col, Kind: mark.Kind}) +} diff --git a/cmd/genspec-tui/internal/ux/index/diagmarks_test.go b/cmd/genspec-tui/internal/ux/index/diagmarks_test.go new file mode 100644 index 00000000..60fa96b8 --- /dev/null +++ b/cmd/genspec-tui/internal/ux/index/diagmarks_test.go @@ -0,0 +1,90 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package index + +import ( + "testing" + + "github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux/theme" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// lexicalSpans is what a `\t// in: formData` line tokenizes to: the comment +// markers, the keyword, then the value back in prose. +func lexicalSpans() map[int][]theme.Span { + return map[int][]theme.Span{ + 4: { + {Col: 2, Kind: theme.SyntaxComment}, + {Col: 5, Kind: theme.SyntaxKeyword}, + {Col: 7, Kind: theme.SyntaxComment}, + }, + } +} + +// The common case, and it is common by construction rather than by luck: a +// diagnostic and a lexical run both address a token, so they agree on where it +// starts. +func TestDiagMarks_ExactHitRestylesTheRun(t *testing.T) { + marked := MarkDiagnostics(lexicalSpans(), []DiagMark{ + {Line: 4, Col: 5, Kind: theme.SyntaxDiagError}, + }) + + assert.Equal(t, []theme.Span{ + {Col: 2, Kind: theme.SyntaxComment}, + {Col: 5, Kind: theme.SyntaxDiagError}, + {Col: 7, Kind: theme.SyntaxComment}, + }, marked[4], "the keyword's own run carries the diagnostic") +} + +// Not every diagnostic lands on a token boundary — codescan reports an invalid +// enum option at the space before the value. Opening a run there over-reaches +// rather than pointing somewhere false. +func TestDiagMarks_InsideARunOpensANewOne(t *testing.T) { + marked := MarkDiagnostics(lexicalSpans(), []DiagMark{ + {Line: 4, Col: 6, Kind: theme.SyntaxDiagWarn}, + }) + + require.Len(t, marked[4], 4) + assert.Equal(t, theme.Span{Col: 6, Kind: theme.SyntaxDiagWarn}, marked[4][2]) + for i := 1; i < len(marked[4]); i++ { + assert.Greater(t, marked[4][i].Col, marked[4][i-1].Col, "runs must stay ordered") + } +} + +// Lexical spans are rebuilt when the BUFFER changes; marks change on every +// rescan. Mutating the input would make a rescan's marks accumulate on top of +// the previous scan's. +func TestDiagMarks_LeavesTheInputAlone(t *testing.T) { + spans := lexicalSpans() + + _ = MarkDiagnostics(spans, []DiagMark{{Line: 4, Col: 5, Kind: theme.SyntaxDiagError}}) + + assert.Equal(t, theme.SyntaxKeyword, spans[4][1].Kind, "the lexical spans are untouched") +} + +func TestDiagMarks_Edges(t *testing.T) { + assert.Nil(t, MarkDiagnostics(nil, []DiagMark{{Line: 0, Col: 1, Kind: theme.SyntaxDiagError}}), + "a file we do not tokenize gets no invented runs") + + spans := lexicalSpans() + assert.Equal(t, spans, MarkDiagnostics(spans, nil), "no marks, nothing to do") + + marked := MarkDiagnostics(lexicalSpans(), []DiagMark{ + {Line: -1, Col: 5, Kind: theme.SyntaxDiagError}, + {Line: 4, Col: 0, Kind: theme.SyntaxDiagError}, + }) + assert.Equal(t, lexicalSpans(), marked, "positionless marks are dropped, not clamped onto line 0") +} + +// A diagnostic on a line with no lexical runs still marks it — a blank or +// unclassified line can carry one. +func TestDiagMarks_LineWithNoRuns(t *testing.T) { + marked := MarkDiagnostics(lexicalSpans(), []DiagMark{ + {Line: 9, Col: 3, Kind: theme.SyntaxDiagHint}, + }) + + assert.Equal(t, []theme.Span{{Col: 3, Kind: theme.SyntaxDiagHint}}, marked[9]) + assert.Len(t, marked[4], 3, "other lines are unaffected") +} diff --git a/cmd/genspec-tui/internal/ux/index/gohighlight.go b/cmd/genspec-tui/internal/ux/index/gohighlight.go new file mode 100644 index 00000000..d3cf03b2 --- /dev/null +++ b/cmd/genspec-tui/internal/ux/index/gohighlight.go @@ -0,0 +1,282 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package index + +import ( + "bytes" + "go/scanner" + gotoken "go/token" + "slices" + "strings" + "unicode/utf8" + + "github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux/theme" + "github.com/go-openapi/codescan/internal/parsers/grammar" +) + +// annotationPrefix is what makes a comment payload rather than prose in this +// app. Comments carrying it are the ONLY reason the source pane exists. +const annotationPrefix = "swagger:" + +// goToken is one token of the scan, retained rather than emitted on the spot. +// Comments cannot be classified in a single forward pass: whether a `required:` +// line is grammar or prose depends on whether the FILE carries annotations at +// all, which is only known once the last line has been read. +type goToken struct { + line, col int // 0-based line, 1-based rune column + tok gotoken.Token + lit string +} + +// BuildGoHighlight classifies Go source into the same per-line lexical runs the +// spec pane uses, so both panes share one renderer and one palette. +// +// The classifier is the standard library's own scanner: it is the definition of +// how Go tokenizes, it costs no dependency, and it is deliberately error +// TOLERANT — a buffer the user is halfway through editing still yields a usable +// token stream instead of nothing. Scan errors are therefore discarded rather +// than reported; a highlighter that gives up on a syntactically incomplete file +// is a highlighter that goes blank exactly when you are typing. +// +// Comments get three classes rather than one, because in a spec generator a +// comment is not uniformly commentary: +// +// - a `swagger:` line is the annotation that declares the thing, and reads as +// a spec key — the input that produced the pane next to it; +// - a leading `:` inside an annotated block is grammar, and reads as +// a keyword, so `// required: true` looks the way `"required": true` does on +// the spec side; +// - everything else is prose, and is dimmed. +func BuildGoHighlight(src []byte) *HighlightIndex { + tokens := scanGo(src) + annotated := hasAnnotation(tokens) + + byLine := make(map[int][]theme.Span) + for _, t := range tokens { + addGoToken(byLine, t, annotated) + } + + return &HighlightIndex{byLine: byLine} +} + +// scanGo tokenizes src into source order. +func scanGo(src []byte) []goToken { + fset := gotoken.NewFileSet() + file := fset.AddFile("", fset.Base(), len(src)) + starts := lineStarts(src) + + var s scanner.Scanner + s.Init(file, src, nil, scanner.ScanComments) // nil handler: errors are not our business + + var out []goToken + for { + pos, tok, lit := s.Scan() + if tok == gotoken.EOF { + break + } + // The scanner synthesises the semicolons Go's grammar requires but the + // source does not write. Marking a run at a character that is not there + // would style the padding past the end of the line. + if tok == gotoken.SEMICOLON && lit == "\n" { + continue + } + + line := file.Line(pos) - 1 + col := runeColumn(src, starts, line, file.Offset(pos)) + if col < 1 { + continue + } + + out = append(out, goToken{line: line, col: col, tok: tok, lit: lit}) + } + + return out +} + +// hasAnnotation reports whether the file carries any annotation at all, which +// is what scopes keyword highlighting. +// +// The FILE is the right unit, and the comment group is not. A field's doc +// comment holds the validation keywords while the `swagger:model` that makes +// them meaningful sits on the enclosing TYPE — so scoping per group lights up +// route and parameter bodies but misses every field constraint, which is most +// of them. Scoping per file follows the convention instead: `name`, `in` and +// `example` are ordinary English words, but in a file that already declares +// annotations a comment leading with one is the keyword far more often than not. +// +// What this cannot know is which declarations the scanner actually visits, so a +// keyword-shaped line in an unrelated comment of an annotated file still lights +// up. That needs the AST, and the AST needs a file that parses — which the +// buffer being edited may not. +func hasAnnotation(tokens []goToken) bool { + for _, t := range tokens { + if t.tok != gotoken.COMMENT { + continue + } + if slices.ContainsFunc(strings.Split(t.lit, "\n"), isAnnotationComment) { + return true + } + } + + return false +} + +// addGoToken records the runs one token contributes, which is more than one when +// the token spans lines. A raw string or a block comment covers every line it +// crosses, and a continuation line carries no token of its own — without a run +// starting at its column 1 it would render plain, and the closing line would go +// plain up to whatever token follows the comment. +func addGoToken(byLine map[int][]theme.Span, t goToken, annotated bool) { + kind := goSyntaxKind(t.tok, t.lit) + + segments := []string{t.lit} + if strings.Contains(t.lit, "\n") { + segments = strings.Split(t.lit, "\n") + } + + for i, segment := range segments { + // A continuation line of an empty segment is a BLANK line inside the + // comment or string. It has no character to mark, and a run there would + // style the padding the pane fills the row with. + if i > 0 && segment == "" { + continue + } + + line, col := t.line+i, t.col + if i > 0 { + col = 1 + } + + if t.tok == gotoken.COMMENT { + addCommentSpans(byLine, line, col, segment, annotated) + + continue + } + + byLine[line] = append(byLine[line], theme.Span{Col: col, Kind: kind}) + } +} + +// addCommentSpans records the runs on one line of a comment. Classification is +// per LINE, so it catches an annotation trailing a declaration +// (AfterDeclComments) and one buried in a block comment just as well as a +// conventional doc block. +func addCommentSpans(byLine map[int][]theme.Span, line, col int, segment string, annotated bool) { + add := func(at int, kind theme.SyntaxKind) { + byLine[line] = append(byLine[line], theme.Span{Col: at, Kind: kind}) + } + + if isAnnotationComment(segment) { + add(col, theme.SyntaxKey) + + return + } + + start, end, ok := grammarKeyword(segment) + if !ok || !annotated { + add(col, theme.SyntaxComment) + + return + } + + // The comment markers before the keyword stay prose; only the keyword + // itself is lifted out, and the value after it returns to prose. + if start > 0 { + add(col, theme.SyntaxComment) + } + add(col+start, theme.SyntaxKeyword) + add(col+end, theme.SyntaxComment) +} + +// grammarKeyword locates a leading `:` in a comment line and returns +// the keyword's rune offsets within it. +// +// Only the text before the FIRST colon is considered, because that is the only +// place the grammar reads a keyword: prose that merely contains a colon does not +// match, and neither does a word the keyword table does not know. The table is +// the parser's own, so what lights up is exactly what the parser will act on — +// aliases (`min` → minimum, `min length` → minLength) and letter case included. +func grammarKeyword(segment string) (start, end int, ok bool) { + body := strings.TrimLeft(segment, " \t/*") + lead := len([]rune(segment)) - len([]rune(body)) + + name, _, found := strings.Cut(body, ":") + if !found { + return 0, 0, false + } + if name = strings.TrimSpace(name); name == "" { + return 0, 0, false + } + if _, known := grammar.Lookup(name); !known { + return 0, 0, false + } + + return lead, lead + len([]rune(name)), true +} + +// goSyntaxKind maps a Go token onto the shared, language-neutral classes. +// Anything unclassified stays SyntaxPlain — identifiers are the bulk of Go +// source, and colouring them would leave nothing uncoloured to contrast against. +func goSyntaxKind(tok gotoken.Token, lit string) theme.SyntaxKind { + switch { + case tok == gotoken.COMMENT: + return theme.SyntaxComment + case tok == gotoken.STRING, tok == gotoken.CHAR: + return theme.SyntaxString + case tok == gotoken.INT, tok == gotoken.FLOAT, tok == gotoken.IMAG: + return theme.SyntaxNumber + case tok.IsKeyword(), tok == gotoken.IDENT && isPredeclaredConst(lit): + return theme.SyntaxKeyword + case tok.IsOperator(): + return theme.SyntaxPunct + default: + return theme.SyntaxPlain + } +} + +// isPredeclaredConst reports whether an identifier is one Go treats as a +// constant rather than a keyword. Colouring them as keywords is what every +// editor does, and `nil` is far too common to read as an ordinary name. +func isPredeclaredConst(lit string) bool { + switch lit { + case "nil", "true", "false", "iota": + return true + default: + return false + } +} + +// isAnnotationComment reports whether a comment line leads with a swagger +// annotation, ignoring the comment markers and indentation around it. +func isAnnotationComment(segment string) bool { + return strings.HasPrefix(strings.TrimLeft(segment, " \t/*"), annotationPrefix) +} + +// lineStarts records the byte offset each 0-based line begins at. +func lineStarts(src []byte) []int { + starts := make([]int, 1, bytes.Count(src, []byte{'\n'})+1) + for i, b := range src { + if b == '\n' { + starts = append(starts, i+1) + } + } + + return starts +} + +// runeColumn converts a byte offset into the 1-based RUNE column the renderer +// slices on. go/token reports columns in bytes, so any multi-byte character +// earlier on the line — an accent in a comment, a symbol in a string — would +// otherwise shift every run after it. Returns 0 for an offset off the line. +func runeColumn(src []byte, starts []int, line, off int) int { + if line < 0 || line >= len(starts) { + return 0 + } + start := starts[line] + if off < start || off > len(src) { + return 0 + } + + return utf8.RuneCount(src[start:off]) + 1 +} diff --git a/cmd/genspec-tui/internal/ux/index/gohighlight_test.go b/cmd/genspec-tui/internal/ux/index/gohighlight_test.go new file mode 100644 index 00000000..f1837fa1 --- /dev/null +++ b/cmd/genspec-tui/internal/ux/index/gohighlight_test.go @@ -0,0 +1,333 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package index + +import ( + "strings" + "testing" + + "github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux/theme" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// 0-based lines: 0 package 2 annotation 3 prose 4 type 5 tagged field +// 6 plain field 9 func with a predeclared const. +const goSrc = "package main\n" + + "\n" + + "// swagger:model User\n" + + "// A user of the system.\n" + + "type User struct {\n" + + "\tName string `json:\"name\"`\n" + + "\tCount int\n" + + "}\n" + + "\n" + + "func f() bool { return nil == nil }\n" + +// firstKind returns the kind of the leftmost run on a 0-based line. +func firstKind(t *testing.T, idx *HighlightIndex, line int) theme.SyntaxKind { + t.Helper() + spans := idx.Spans(line) + require.NotEmpty(t, spans, "line %d carries no runs", line) + + return spans[0].Kind +} + +func TestGoHighlight_ClassifiesTokens(t *testing.T) { + idx := BuildGoHighlight([]byte(goSrc)) + require.Positive(t, idx.Len()) + + assert.Equal(t, theme.SyntaxKeyword, firstKind(t, idx, 0), "`package` is a keyword") + assert.Equal(t, theme.SyntaxKeyword, firstKind(t, idx, 4), "`type` is a keyword") + + // A struct tag is a string literal; the field NAME and its type stay plain, + // because identifiers are the bulk of Go source and need something to + // contrast against. + assert.Contains(t, kindsOn(idx, 5), theme.SyntaxString, "the struct tag") + assert.Contains(t, kindsOn(idx, 5), theme.SyntaxPlain, "Name / string are identifiers") + + assert.Contains(t, kindsOn(idx, 9), theme.SyntaxKeyword, "`func`, `return` and `nil`") + assert.Contains(t, kindsOn(idx, 9), theme.SyntaxPunct, "the parens and braces") +} + +// The whole point of this pane is the annotations, so they must not read as +// dimmed-away commentary like the prose around them. +func TestGoHighlight_AnnotationCommentsOutrankOrdinaryOnes(t *testing.T) { + idx := BuildGoHighlight([]byte(goSrc)) + + assert.Equal(t, theme.SyntaxKey, firstKind(t, idx, 2), "// swagger:model User") + assert.Equal(t, theme.SyntaxComment, firstKind(t, idx, 3), "// A user of the system.") +} + +// An annotation may sit on any line of a doc block, or trail a declaration +// (AfterDeclComments) — classification is per line, not per comment token. +func TestGoHighlight_AnnotationAnywhereInABlock(t *testing.T) { + src := "package p\n" + + "\n" + + "/*\n" + + "A user.\n" + + "swagger:model User\n" + + "*/\n" + + "type User struct{} // swagger:model Other\n" + + idx := BuildGoHighlight([]byte(src)) + + assert.Equal(t, theme.SyntaxComment, firstKind(t, idx, 3), "prose line of the block") + assert.Equal(t, theme.SyntaxKey, firstKind(t, idx, 4), "annotation line of the block") + + // The trailing comment is a run of its own, after the declaration's runs. + trailing := idx.Spans(6) + require.NotEmpty(t, trailing) + assert.Equal(t, theme.SyntaxKey, trailing[len(trailing)-1].Kind, + "an inlined annotation is still the payload") + assert.NotEqual(t, theme.SyntaxKey, trailing[0].Kind, "the code before it is not") +} + +// A block comment or raw string covers lines that contain no token of their +// own. Without a run starting at their column 1 they would render plain, and +// the closing line would go plain up to whatever token follows. +func TestGoHighlight_MultiLineTokensCoverEveryLine(t *testing.T) { + src := "package p\n" + + "\n" + + "/* block\n" + + " still block */\n" + + "var x = `raw\n" + + "string`\n" + + idx := BuildGoHighlight([]byte(src)) + + for _, tc := range []struct { + line int + kind theme.SyntaxKind + why string + }{ + {2, theme.SyntaxComment, "the block comment opens"}, + {3, theme.SyntaxComment, "and continues onto a line with no token of its own"}, + {5, theme.SyntaxString, "the raw string continues"}, + } { + spans := idx.Spans(tc.line) + require.NotEmpty(t, spans, "line %d: %s", tc.line, tc.why) + assert.Equal(t, tc.kind, spans[0].Kind, "line %d: %s", tc.line, tc.why) + } + + assert.Equal(t, 1, idx.Spans(3)[0].Col, "a continuation run starts at the first column") + assert.Equal(t, 1, idx.Spans(5)[0].Col) +} + +// go/token counts columns in BYTES; the renderer slices in RUNES. Any +// multi-byte character earlier on the line shifts every run after it. +func TestGoHighlight_ColumnsAreRunesNotBytes(t *testing.T) { + // c1..5 `const`, 7 a, 8 comma, 10 b, 12 `=`, 14 the string (7 runes), 21 + // comma, 23 the number — which would be byte column 24, `é` being 2 bytes. + src := "package p\n\nconst a, b = \"héllo\", 42\n" + + idx := BuildGoHighlight([]byte(src)) + + var numberCol int + for _, sp := range idx.Spans(2) { + if sp.Kind == theme.SyntaxNumber { + numberCol = sp.Col + } + } + assert.Equal(t, 23, numberCol, "byte columns would report 24") + + line := []rune(strings.Split(src, "\n")[2]) + require.Equal(t, "42", string(line[numberCol-1:numberCol+1]), + "the column must actually land on the token") +} + +// The buffer is a file someone is halfway through editing. A highlighter that +// gives up on incomplete syntax goes blank exactly when you are typing. +func TestGoHighlight_TolerantOfBrokenSource(t *testing.T) { + src := "package p\n\nfunc f( {\n\tx := \"unterminated\n" + + idx := BuildGoHighlight([]byte(src)) + + assert.Positive(t, idx.Len(), "a broken buffer still highlights") + assert.Equal(t, theme.SyntaxKeyword, firstKind(t, idx, 2), "`func` is still a keyword") +} + +// Runs must ascend, or the renderer takes one back past the previous one and +// paints the line wrong. +func TestGoHighlight_SpansAscendByColumn(t *testing.T) { + idx := BuildGoHighlight([]byte(goSrc)) + + for line := range strings.Count(goSrc, "\n") + 1 { + spans := idx.Spans(line) + for i, sp := range spans { + assert.Positive(t, sp.Col, "line %d: columns are 1-based", line) + if i > 0 { + assert.Greater(t, sp.Col, spans[i-1].Col, "line %d: runs must not overlap", line) + } + } + } +} + +// The scanner synthesises the semicolons the source does not write. Marking a +// run at a character that is not there styles the padding past the line's end. +func TestGoHighlight_NoRunPastTheEndOfALine(t *testing.T) { + // The blank line inside the block comment is the case a walk over the + // fixture corpus turned up: a continuation run would mark column 1 of a + // line that has no column 1, styling the padding the pane fills it with. + src := "package p\n" + + "\n" + + "/* a\n" + + "\n" + + " b */\n" + + "var x = 1\n" + lines := strings.Split(src, "\n") + + idx := BuildGoHighlight([]byte(src)) + + for line, text := range lines { + for _, sp := range idx.Spans(line) { + assert.LessOrEqual(t, sp.Col, len([]rune(text)), + "line %d (%q): a run starts past its last character", line, text) + } + } +} + +func TestGoHighlight_Empty(t *testing.T) { + idx := BuildGoHighlight(nil) + + require.NotNil(t, idx, "an empty file still yields an index, not nil") + assert.Zero(t, idx.Len()) +} + +// A model with its constraints where go-swagger actually puts them: the +// annotation on the TYPE, the keywords in each FIELD's doc comment. +const goKeywordSrc = "package main\n" + // 0 + "\n" + // 1 + "// swagger:model User\n" + // 2 + "type User struct {\n" + // 3 + "\t// the user's name\n" + // 4 + "\t//\n" + // 5 + "\t// required: true\n" + // 6 + "\t// min length: 3\n" + // 7 + "\t// note: not a grammar keyword\n" + // 8 + "\t// the id of the pet: as an integer\n" + // 9 + "\tName string\n" + // 10 + "}\n" // 11 + +// runAt returns the text and kind of the i-th run on a line, taking each run to +// the next one's column — the same rule the renderer uses. +func runAt(t *testing.T, idx *HighlightIndex, lines []string, line, i int) (string, theme.SyntaxKind) { + t.Helper() + spans := idx.Spans(line) + require.Greater(t, len(spans), i, "line %d carries no run %d", line, i) + + runes := []rune(lines[line]) + start := spans[i].Col - 1 + end := len(runes) + if i+1 < len(spans) { + end = spans[i+1].Col - 1 + } + require.LessOrEqual(t, end, len(runes), "line %d run %d ends past the line", line, i) + + return string(runes[start:end]), spans[i].Kind +} + +// The keyword is lifted out of the prose around it rather than the whole line +// being recoloured, so `// required: true` reads the way `"required": true` +// does on the spec side. +func TestGoHighlight_GrammarKeywordIsLiftedOutOfTheComment(t *testing.T) { + idx := BuildGoHighlight([]byte(goKeywordSrc)) + lines := strings.Split(goKeywordSrc, "\n") + + marker, markerKind := runAt(t, idx, lines, 6, 0) + keyword, keywordKind := runAt(t, idx, lines, 6, 1) + value, valueKind := runAt(t, idx, lines, 6, 2) + + assert.Equal(t, "// ", marker, "the run starts at the comment token; the indent precedes it") + assert.Equal(t, theme.SyntaxComment, markerKind, "the comment markers stay prose") + assert.Equal(t, "required", keyword) + assert.Equal(t, theme.SyntaxKeyword, keywordKind) + assert.Equal(t, ": true", value) + assert.Equal(t, theme.SyntaxComment, valueKind, "the value returns to prose") +} + +// The table is the parser's own, so aliases and letter case come for free — and +// a multi-word keyword must be covered whole, not up to its first space. +func TestGoHighlight_GrammarKeywordAliasesAndMultiWord(t *testing.T) { + idx := BuildGoHighlight([]byte(goKeywordSrc)) + lines := strings.Split(goKeywordSrc, "\n") + + keyword, kind := runAt(t, idx, lines, 7, 1) + + assert.Equal(t, "min length", keyword, "`min length` is one keyword, not `min`") + assert.Equal(t, theme.SyntaxKeyword, kind) + + for _, spelling := range []string{"Min Length", "MIN LENGTH", "minLength"} { + src := "package p\n\n// swagger:model U\n// " + spelling + ": 3\n" + byLine := BuildGoHighlight([]byte(src)) + assert.Contains(t, kindsOn(byLine, 3), theme.SyntaxKeyword, spelling) + } +} + +// Only a LEADING keyword counts, and only one the table knows. Prose that +// merely contains a colon is still prose. +func TestGoHighlight_ProseIsNotMistakenForGrammar(t *testing.T) { + idx := BuildGoHighlight([]byte(goKeywordSrc)) + + for _, tc := range []struct { + line int + why string + }{ + {4, "a plain description"}, + {8, "`note` is not in the keyword table"}, + {9, "a colon buried in prose is not a keyword separator"}, + } { + assert.Equal(t, []theme.SyntaxKind{theme.SyntaxComment}, kindsOn(idx, tc.line), tc.why) + } +} + +// Scope is the FILE: `name`, `in` and `example` are ordinary English words, so +// a file that declares no annotations gets no keyword highlighting at all. +func TestGoHighlight_KeywordsOnlyInAnnotatedFiles(t *testing.T) { + src := "package p\n" + + "\n" + + "// helper does things.\n" + + "// name: not a keyword here\n" + + "// required: nor this\n" + + "func helper() {}\n" + + idx := BuildGoHighlight([]byte(src)) + + for _, line := range []int{2, 3, 4} { + assert.Equal(t, []theme.SyntaxKind{theme.SyntaxComment}, kindsOn(idx, line), + "line %d: nothing in this file is annotated", line) + } +} + +// The regression a walk over the fixture corpus caught: scoping to the comment +// GROUP lit up route and parameter bodies but missed every field constraint, +// because the `swagger:model` that makes them meaningful sits on the type. +func TestGoHighlight_FieldConstraintsUnderATypeAnnotation(t *testing.T) { + idx := BuildGoHighlight([]byte(goKeywordSrc)) + + assert.Contains(t, kindsOn(idx, 6), theme.SyntaxKeyword, + "the annotation is on the type, two comment groups away") + assert.Contains(t, kindsOn(idx, 7), theme.SyntaxKeyword) +} + +// The annotation itself is not split: it names the block rather than setting a +// property, and keeps the spec-key class over the whole line. +func TestGoHighlight_AnnotationLineIsNotSplitIntoKeywordRuns(t *testing.T) { + idx := BuildGoHighlight([]byte(goKeywordSrc)) + + assert.Equal(t, []theme.SyntaxKind{theme.SyntaxKey}, kindsOn(idx, 2)) +} + +// Runs must ascend here too — three spans on one line is where an off-by-one +// in the keyword offsets would show up as an overlapping paint. +func TestGoHighlight_KeywordRunsAscend(t *testing.T) { + idx := BuildGoHighlight([]byte(goKeywordSrc)) + + for line := range strings.Count(goKeywordSrc, "\n") + 1 { + spans := idx.Spans(line) + for i := 1; i < len(spans); i++ { + assert.Greater(t, spans[i].Col, spans[i-1].Col, "line %d", line) + } + } +} diff --git a/cmd/genspec-tui/internal/ux/index/highlight.go b/cmd/genspec-tui/internal/ux/index/highlight.go new file mode 100644 index 00000000..51362c09 --- /dev/null +++ b/cmd/genspec-tui/internal/ux/index/highlight.go @@ -0,0 +1,103 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package index + +import ( + "sort" + + "github.com/go-openapi/core/json/lexers/token" + + "github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux/theme" +) + +// HighlightIndex maps each rendered line to the lexical runs on it. +// +// It costs nothing to produce: the lexer already classifies every token while +// the pointer index is being built, and until now that classification was +// thrown away. Re-parsing the same bytes with a separate highlighting library +// would be both a second pass and a dependency, for information already in hand. +// +// Spans record only where a run STARTS. The renderer takes each run to the next +// span's column, which is what lets it slice RAW text at known boundaries and +// apply styling last — the only ordering in which a truncated line cannot cut +// through an escape sequence. +type HighlightIndex struct { + byLine map[int][]theme.Span +} + +// Spans returns the runs on a 0-based rendered line, ordered by column, or nil +// when the line has none. +func (x *HighlightIndex) Spans(line int) []theme.Span { + if x == nil { + return nil + } + + return x.byLine[line] +} + +// All returns the whole per-line map, for a renderer that wants to install it +// once rather than query per line. Nil for a nil index. +func (x *HighlightIndex) All() map[int][]theme.Span { + if x == nil { + return nil + } + + return x.byLine +} + +// Len reports how many lines carry spans (0 for a nil index). +func (x *HighlightIndex) Len() int { + if x == nil { + return 0 + } + + return len(x.byLine) +} + +// syntaxKind maps a lexer token to the renderer's neutral classes. Delimiters +// carry no value and are the structural punctuation; keys are distinguished +// from strings because in a spec the key is what you scan for. +func syntaxKind(k token.Kind) theme.SyntaxKind { + switch k { + case token.Key: + return theme.SyntaxKey + case token.String: + return theme.SyntaxString + case token.Number: + return theme.SyntaxNumber + case token.Boolean, token.Null: + return theme.SyntaxKeyword + case token.Delimiter: + return theme.SyntaxPunct + case token.Unknown, token.EOF: + return theme.SyntaxPlain + default: + return theme.SyntaxPlain + } +} + +// addSpan records one token's run. line is 0-based; col is the lexer's 1-based +// column. Tokens with no position (the EOF delimiters the YAML lexer reports at +// line 0) are dropped rather than attributed to the first line. +func (a *indexAccum) addSpan(line, col int, kind token.Kind) { + if line < 0 || col < 1 { + return + } + a.spans[line] = append(a.spans[line], theme.Span{Col: col, Kind: syntaxKind(kind)}) +} + +// finishSpans orders each line's runs by column. +// +// The sort is required, not defensive: the YAML lexer currently reports a +// mapping's value-delimiter BEFORE the key on the same line, so the stream is +// not in source order. Fred plans to improve that lexer's column reporting +// upstream; when it lands in source order this sort becomes a no-op and can go. +func (a *indexAccum) finishSpans() *HighlightIndex { + for line := range a.spans { + runs := a.spans[line] + sort.Slice(runs, func(i, j int) bool { return runs[i].Col < runs[j].Col }) + } + + return &HighlightIndex{byLine: a.spans} +} diff --git a/cmd/genspec-tui/internal/ux/index/highlight_test.go b/cmd/genspec-tui/internal/ux/index/highlight_test.go new file mode 100644 index 00000000..869d9253 --- /dev/null +++ b/cmd/genspec-tui/internal/ux/index/highlight_test.go @@ -0,0 +1,134 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package index + +import ( + "testing" + + "github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux/theme" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +const hlJSON = `{ + "definitions": { + "User": { + "count": 3, + "ok": true, + "boss": null, + "name": "x" + } + } +}` + +// kindsOn returns the span kinds on a 0-based line, in column order. +func kindsOn(idx *HighlightIndex, line int) []theme.SyntaxKind { + spans := idx.Spans(line) + out := make([]theme.SyntaxKind, 0, len(spans)) + for _, sp := range spans { + out = append(out, sp.Kind) + } + + return out +} + +func TestHighlight_ClassifiesJSONTokens(t *testing.T) { + idx := BuildJSONIndex([]byte(hlJSON)).Highlight + require.Positive(t, idx.Len()) + + // ` "count": 3,` + assert.Equal(t, + []theme.SyntaxKind{theme.SyntaxKey, theme.SyntaxPunct, theme.SyntaxNumber, theme.SyntaxPunct}, + kindsOn(idx, 3)) + + // booleans and null are one class — they are the literal keywords + assert.Contains(t, kindsOn(idx, 4), theme.SyntaxKeyword, "true") + assert.Contains(t, kindsOn(idx, 5), theme.SyntaxKeyword, "null") + + // a string VALUE is distinct from a key, which is what you scan a spec for + assert.Contains(t, kindsOn(idx, 6), theme.SyntaxString) +} + +// Spans record where a run starts, so columns must be 1-based and ascending — +// the renderer takes each run to the next one's column. +func TestHighlight_SpansAreOrderedByColumn(t *testing.T) { + idx := BuildJSONIndex([]byte(hlJSON)).Highlight + + for line := range 8 { + spans := idx.Spans(line) + for i, sp := range spans { + assert.Positive(t, sp.Col, "line %d span %d: columns are 1-based", line, i) + if i > 0 { + assert.Greater(t, sp.Col, spans[i-1].Col, + "line %d: spans must ascend, or runs would overlap", line) + } + } + } +} + +// The YAML lexer currently reports a mapping's value-delimiter BEFORE the key on +// the same line, so the accumulator sorts. Without that, the renderer would take +// the first run from column 12 back to column 1 and paint the line wrong. +func TestHighlight_YAMLSpansAreSortedDespiteEmissionOrder(t *testing.T) { + const hlYAML = `definitions: + User: + count: 3 + ok: true +` + idx := BuildYAMLIndex([]byte(hlYAML)).Highlight + require.Positive(t, idx.Len()) + + for line := range 4 { + spans := idx.Spans(line) + for i := 1; i < len(spans); i++ { + assert.Greater(t, spans[i].Col, spans[i-1].Col, "line %d", line) + } + } + + // Line 0 is `definitions:` — the key must come first despite being emitted + // after its delimiter. + first := idx.Spans(0) + require.NotEmpty(t, first) + assert.Equal(t, theme.SyntaxKey, first[0].Kind) + assert.Equal(t, 1, first[0].Col) +} + +// The YAML lexer reports its trailing EOF delimiters at line 0 / column 0. +// Attributing those to the first line would paint a run that is not there. +func TestHighlight_DropsPositionlessTokens(t *testing.T) { + idx := BuildYAMLIndex([]byte("a: 1\n")).Highlight + + for _, sp := range idx.Spans(0) { + assert.Positive(t, sp.Col) + } + assert.Empty(t, idx.Spans(-1), "nothing is filed under a negative line") +} + +func TestHighlight_NilAndEmpty(t *testing.T) { + var nilIdx *HighlightIndex + assert.Empty(t, nilIdx.Spans(0)) + assert.Zero(t, nilIdx.Len()) + assert.Nil(t, nilIdx.All()) + + idx := BuildJSONIndex([]byte(`{}`)).Highlight + assert.NotNil(t, idx, "an empty document still yields an index, not nil") +} + +// One walk, three products — adding the highlight index must not have cost a +// second traversal, nor disturbed the other two. +func TestHighlight_SharesTheWalkWithTheOtherIndexes(t *testing.T) { + built := BuildJSONIndex([]byte(hlJSON)) + + require.NotNil(t, built.Spec) + require.NotNil(t, built.Refs) + require.NotNil(t, built.Highlight) + + _, ok := built.Spec.LineForPointer("/definitions/User/properties") + assert.False(t, ok, "this fixture has no properties node") + + line, ok := built.Spec.LineForPointer("/definitions/User/count") + require.True(t, ok) + assert.NotEmpty(t, built.Highlight.Spans(line), + "the line the pointer index found must also carry spans") +} diff --git a/cmd/genspec-tui/internal/ux/index/refindex.go b/cmd/genspec-tui/internal/ux/index/refindex.go new file mode 100644 index 00000000..4487180d --- /dev/null +++ b/cmd/genspec-tui/internal/ux/index/refindex.go @@ -0,0 +1,223 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package index + +import ( + "net/url" + "sort" + "strings" + + "github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux/theme" +) + +// refSuffix is the pointer tail a $ref member carries. Both lexers report the +// member's own pointer (…/boss/$ref) for the key AND for its value token. +const refSuffix = "/$ref" + +// RefTarget is a parsed $ref value. +// +// Local refs point inside the document being rendered and can therefore be +// followed with the SpecIndex; anything else (a sibling file, a URL, a bare +// filename) is recorded honestly but is not resolvable here — the TUI renders +// one spec, it is not a $ref resolver. +type RefTarget struct { + Raw string // exactly as written in the document + Pointer string // the RFC 6901 pointer for a local ref; "" otherwise + Local bool // whether the ref points inside this document +} + +// RefSite is one place in the rendered spec where a $ref appears. +type RefSite struct { + Pointer string // the node HOLDING the $ref (the /$ref segment trimmed) + Line int // 0-based rendered line of the $ref + Target RefTarget +} + +// RefIndex records every $ref in a rendered spec, keyed by what it points at. +// +// This is the "find references" half of Phase D (design §3.4): a decl is +// anchored to its own field, never to the type it references, so answering +// "where is this definition used?" means resolving $refs at RENDER time. That +// makes the index per-render, exactly like SpecIndex — and it is built in the +// same lexer pass, so it costs no extra walk. +// +// Scope, deliberately: this is a SITE index, not a JSON-Schema resolver. It +// records where each $ref token sits and what string it holds. It does not +// follow ref-to-ref chains, does not reason about $refs nested in allOf, and +// does not apply the "sibling keywords are ignored" rule — the §3.4 quirks stay +// documented rather than chased. +type RefIndex struct { + byTarget map[string][]RefSite // local target pointer → sites, ordered by line + byLine map[int]RefSite // rendered line → the $ref on it + total int +} + +// ParseRefTarget parses a raw $ref value. +// +// A local ref is a bare fragment ("#/definitions/User"). The fragment is +// percent-DECODED, because go-openapi/spec marshals refs through net/url and +// will escape a definition name containing e.g. a space — while the SpecIndex +// keys on the document's own key text, which is not escaped. Decoding here is +// what makes the two sides comparable. A malformed escape falls back to the +// verbatim fragment rather than dropping the ref. +func ParseRefTarget(raw string) RefTarget { + t := RefTarget{Raw: raw} + if !strings.HasPrefix(raw, "#") { + return t // another document, a URL, or a bare filename + } + frag := strings.TrimPrefix(raw, "#") + if frag != "" && !strings.HasPrefix(frag, "/") { + return t // "#Foo" — a plain-name fragment, not a JSON pointer + } + decoded, err := url.PathUnescape(frag) + if err != nil { + decoded = frag + } + t.Pointer, t.Local = decoded, true + + return t +} + +// RefsToPointer returns every site referencing the node at ptr, ordered by +// rendered line. ptr is a plain JSON pointer as the SpecIndex reports it +// (e.g. "/definitions/User") — the leading "#" of the $ref is not included. +func (x *RefIndex) RefsToPointer(ptr string) []RefSite { + if x == nil { + return nil + } + + return x.byTarget[ptr] +} + +// RefsNear returns the sites referencing ptr — or, when nothing references ptr +// itself, the sites referencing its nearest referenced ANCESTOR, along with the +// pointer that actually matched. +// +// The segment-trim walk mirrors SourceIndex.PositionFor, and for the same +// reason: the user's cursor is rarely on the definition line itself. Asking for +// the references of `/definitions/User/properties/name` should find the uses of +// `User`, not report nothing. +func (x *RefIndex) RefsNear(ptr string) (string, []RefSite) { + if x == nil { + return "", nil + } + for ptr != "" { + if sites := x.byTarget[ptr]; len(sites) > 0 { + return ptr, sites + } + i := strings.LastIndexByte(ptr, '/') + if i < 0 { + break + } + ptr = ptr[:i] + } + + return "", nil +} + +// RefAt returns the $ref rendered on the given 0-based line, if any. Backs +// go-to-definition: the user puts the cursor on a $ref and follows it. +func (x *RefIndex) RefAt(line int) (RefSite, bool) { + if x == nil { + return RefSite{}, false + } + site, ok := x.byLine[line] + + return site, ok +} + +// LocalRefLines returns the 0-based rendered lines holding a FOLLOWABLE $ref, +// i.e. one pointing inside this document. Backs the spec pane's gutter: an +// external ref is not marked, because Enter cannot take you there. +func (x *RefIndex) LocalRefLines() []int { + if x == nil { + return nil + } + out := make([]int, 0, len(x.byLine)) + for line, site := range x.byLine { + if site.Target.Local { + out = append(out, line) + } + } + + return out +} + +// Len reports how many $ref sites the index holds (0 for a nil index), +// including non-local ones. +func (x *RefIndex) Len() int { + if x == nil { + return 0 + } + + return x.total +} + +// indexAccum accumulates both per-render indexes during one lexer walk. The two +// lexers (JSON and YAML) emit different concrete types but the same logical +// stream, so each drives this from its own loop. +type indexAccum struct { + line2ptr map[int]string + ptr2line map[string]int + byTarget map[string][]RefSite + byLine map[int]RefSite + total int + spans map[int][]theme.Span +} + +func newIndexAccum() *indexAccum { + return &indexAccum{ + line2ptr: make(map[int]string), + ptr2line: make(map[string]int), + byTarget: make(map[string][]RefSite), + byLine: make(map[int]RefSite), + spans: make(map[int][]theme.Span), + } +} + +// add folds one token into both indexes. ptr is the token's JSON pointer, line +// its 0-based rendered line, isKey whether it is an object key, and value its +// raw scalar text (empty for delimiters). +func (a *indexAccum) add(ptr string, line int, isKey bool, value []byte) { + if ptr == "" { + return + } + + // Spec index: the FIRST token to report a pointer is the line that pointer + // is declared on; later repeats (its value, its closing delimiter) are the + // same node seen again. + if _, seen := a.ptr2line[ptr]; !seen { + a.ptr2line[ptr] = line + a.line2ptr[line] = ptr + } + + // Ref index: the VALUE token under a …/$ref pointer carries the target. + // The key token shares that pointer, hence the isKey guard. + if isKey || len(value) == 0 || !strings.HasSuffix(ptr, refSuffix) { + return + } + site := RefSite{ + Pointer: strings.TrimSuffix(ptr, refSuffix), + Line: line, + Target: ParseRefTarget(string(value)), + } + a.total++ + a.byLine[line] = site + if site.Target.Local { + a.byTarget[site.Target.Pointer] = append(a.byTarget[site.Target.Pointer], site) + } +} + +func (a *indexAccum) finish() Indexes { + for target := range a.byTarget { + sites := a.byTarget[target] + sort.Slice(sites, func(i, j int) bool { return sites[i].Line < sites[j].Line }) + } + + return Indexes{ + Spec: NewSpecIndex(a.line2ptr, a.ptr2line), + Refs: &RefIndex{byTarget: a.byTarget, byLine: a.byLine, total: a.total}, + Highlight: a.finishSpans(), + } +} diff --git a/cmd/genspec-tui/internal/ux/index/refindex_test.go b/cmd/genspec-tui/internal/ux/index/refindex_test.go new file mode 100644 index 00000000..ffd92edb --- /dev/null +++ b/cmd/genspec-tui/internal/ux/index/refindex_test.go @@ -0,0 +1,190 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package index + +import ( + "testing" + + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// refSpecJSON references /definitions/User from three places — a property, an +// array's items, and a response schema — plus one external ref that must be +// recorded but not resolvable, and a ref to a path key needing RFC 6901 +// escaping. Indented exactly as json.MarshalIndent renders. +const refSpecJSON = `{ + "definitions": { + "Team": { + "properties": { + "lead": { + "$ref": "#/definitions/User" + }, + "members": { + "items": { + "$ref": "#/definitions/User" + }, + "type": "array" + }, + "logo": { + "$ref": "https://example.com/schemas/logo.json#/Logo" + } + } + }, + "User": { + "properties": { + "name": { + "type": "string" + } + } + } + }, + "paths": { + "/pets": { + "get": { + "responses": { + "200": { + "schema": { + "$ref": "#/definitions/User" + } + } + } + } + } + } +}` + +// 0-based rendered lines of the four $ref values above. +const ( + refLineLead = 5 + refLineMembers = 9 + refLineLogo = 14 + refLineResponse = 32 + refLineUserDecl = 18 +) + +func TestRefIndex_FindsEveryLocalSite(t *testing.T) { + refs := BuildJSONIndex([]byte(refSpecJSON)).Refs + + sites := refs.RefsToPointer("/definitions/User") + require.Len(t, sites, 3, "a property, an items schema and a response schema reference User") + + // Ordered by rendered line, which is the order F3 will step through them. + assert.Equal(t, []int{refLineLead, refLineMembers, refLineResponse}, + []int{sites[0].Line, sites[1].Line, sites[2].Line}) + + // Each site names the node HOLDING the $ref, not the $ref member itself — + // that is the node the user is navigating to. + assert.Equal(t, "/definitions/Team/properties/lead", sites[0].Pointer) + assert.Equal(t, "/definitions/Team/properties/members/items", sites[1].Pointer) + assert.Equal(t, "/paths/~1pets/get/responses/200/schema", sites[2].Pointer, + "the path key keeps its RFC 6901 escaping") +} + +func TestRefIndex_ExternalRefsAreRecordedNotResolved(t *testing.T) { + refs := BuildJSONIndex([]byte(refSpecJSON)).Refs + + assert.Equal(t, 4, refs.Len(), "the external ref is counted") + assert.Empty(t, refs.RefsToPointer("/Logo"), + "an external ref must not be matched as if it were local") + + site, ok := refs.RefAt(refLineLogo) + require.True(t, ok, "the external ref is still locatable by line") + assert.False(t, site.Target.Local) + assert.Empty(t, site.Target.Pointer) + assert.Equal(t, "https://example.com/schemas/logo.json#/Logo", site.Target.Raw, + "the raw value is preserved verbatim, so the UI can say where it points") +} + +func TestRefIndex_RefAt(t *testing.T) { + refs := BuildJSONIndex([]byte(refSpecJSON)).Refs + + site, ok := refs.RefAt(refLineLead) + require.True(t, ok) + assert.Equal(t, "/definitions/User", site.Target.Pointer) + assert.True(t, site.Target.Local) + + _, ok = refs.RefAt(0) // the opening brace: no $ref there + assert.False(t, ok) +} + +// The whole point of the index is the join: a site's target must be a pointer +// the SpecIndex can actually resolve to a line. +func TestRefIndex_TargetsResolveInTheSpecIndex(t *testing.T) { + built := BuildJSONIndex([]byte(refSpecJSON)) + spec, refs := built.Spec, built.Refs + + for _, site := range refs.RefsToPointer("/definitions/User") { + line, ok := spec.LineForPointer(site.Target.Pointer) + require.True(t, ok, "target %q must exist in the spec index", site.Target.Pointer) + assert.Equal(t, refLineUserDecl, line, "/definitions/User is declared once") + } +} + +// YAML renders the same document at different lines; the pointers must match +// the JSON side exactly, so navigation survives a format toggle. +func TestRefIndex_YAMLMatchesJSONPointers(t *testing.T) { + const refSpecYAML = `definitions: + Team: + properties: + lead: + $ref: '#/definitions/User' + members: + items: + $ref: '#/definitions/User' + type: array + User: + properties: + name: + type: string +` + refs := BuildYAMLIndex([]byte(refSpecYAML)).Refs + + sites := refs.RefsToPointer("/definitions/User") + require.Len(t, sites, 2) + assert.Equal(t, "/definitions/Team/properties/lead", sites[0].Pointer) + assert.Equal(t, "/definitions/Team/properties/members/items", sites[1].Pointer) + assert.Equal(t, []int{4, 7}, []int{sites[0].Line, sites[1].Line}, + "same nodes, YAML line numbers") +} + +func TestParseRefTarget(t *testing.T) { + for _, c := range []struct { + name string + raw string + local bool + pointer string + }{ + {"local definition", "#/definitions/User", true, "/definitions/User"}, + {"local with escaped path key", "#/paths/~1pets/get", true, "/paths/~1pets/get"}, + {"local percent-encoded", "#/definitions/A%20B", true, "/definitions/A B"}, + {"hierarchical name", "#/definitions/pkg/Name", true, "/definitions/pkg/Name"}, + {"whole document", "#", true, ""}, + {"external file", "other.json", false, ""}, + {"external with fragment", "other.json#/definitions/User", false, ""}, + {"url", "https://example.com/s.json#/X", false, ""}, + {"plain-name fragment", "#Foo", false, ""}, + // A malformed escape must not drop the ref on the floor. + {"bad percent escape", "#/definitions/A%zz", true, "/definitions/A%zz"}, + } { + t.Run(c.name, func(t *testing.T) { + got := ParseRefTarget(c.raw) + assert.Equal(t, c.raw, got.Raw) + assert.Equal(t, c.local, got.Local) + assert.Equal(t, c.pointer, got.Pointer) + }) + } +} + +func TestRefIndex_NilAndEmpty(t *testing.T) { + var nilIdx *RefIndex + assert.Empty(t, nilIdx.RefsToPointer("/definitions/User")) + assert.Zero(t, nilIdx.Len()) + _, ok := nilIdx.RefAt(0) + assert.False(t, ok) + + refs := BuildJSONIndex([]byte(`{"definitions":{}}`)).Refs + assert.Zero(t, refs.Len()) + assert.Empty(t, refs.RefsToPointer("/definitions/User")) +} diff --git a/cmd/genspec-tui/internal/ux/index/sourceindex.go b/cmd/genspec-tui/internal/ux/index/sourceindex.go new file mode 100644 index 00000000..b71e506b --- /dev/null +++ b/cmd/genspec-tui/internal/ux/index/sourceindex.go @@ -0,0 +1,147 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package index + +import ( + "go/token" + "sort" + "strings" + + "github.com/go-openapi/codescan/internal/scanner" +) + +// SourceIndex is the caller-owned source-side half of the cross-ref linker +// (design §3): it maps the RFC 6901 JSON pointers codescan emits via +// OnProvenance to the Go source position that produced them, and back. codescan +// anchors only code-detail nodes, so this is NOT a bijection — a finer pointer +// resolves to its nearest anchored ancestor (PositionFor), and a source line +// resolves to its nearest enclosing anchor (PointerAt). +type SourceIndex struct { + fwd map[string]token.Position // pointer → source position + byFile map[string][]lineAnchor // absolute file → anchors sorted by line +} + +// lineAnchor is one (1-based source line → pointer) entry within a file, for the +// reverse source→spec lookup. +type lineAnchor struct { + line int + ptr string +} + +// BuildSourceIndex builds the index from the provenance records collected during +// a scan (one OnProvenance call each). Later records win on a duplicate pointer +// (upsert / last-wins), matching codescan's build, where a node may be rewritten. +func BuildSourceIndex(provs []scanner.Provenance) *SourceIndex { + x := &SourceIndex{ + fwd: make(map[string]token.Position, len(provs)), + byFile: make(map[string][]lineAnchor), + } + for _, p := range provs { + x.fwd[p.Pointer] = p.Pos + if p.Pos.Filename != "" { + x.byFile[p.Pos.Filename] = append(x.byFile[p.Pos.Filename], lineAnchor{line: p.Pos.Line, ptr: p.Pointer}) + } + } + for f := range x.byFile { + anchors := x.byFile[f] + sort.Slice(anchors, func(i, j int) bool { return anchors[i].line < anchors[j].line }) + } + return x +} + +// Len reports how many anchored pointers the index holds (0 for a nil index). +func (x *SourceIndex) Len() int { + if x == nil { + return 0 + } + return len(x.fwd) +} + +// PositionFor returns the source position anchored to ptr, or — when ptr itself +// is a finer node with no anchor of its own — the position of its nearest +// anchored ancestor. The walk trims one pointer segment at a time (a zero-alloc +// suffix shrink), so /definitions/User/properties/x/items resolves to +// /definitions/User/properties/x, then /definitions/User, … until a hit. +func (x *SourceIndex) PositionFor(ptr string) (token.Position, bool) { + if x == nil { + return token.Position{}, false + } + for ptr != "" { + if pos, ok := x.fwd[ptr]; ok { + return pos, true + } + i := strings.LastIndexByte(ptr, '/') + if i < 0 { + break + } + ptr = ptr[:i] + } + return token.Position{}, false +} + +// FirstAnchor returns the pointer of the earliest (lowest-line) anchor recorded +// in file, or ok=false when the file produced no spec node. Backs the tree's +// "locate this file in the spec" jump. +func (x *SourceIndex) FirstAnchor(file string) (string, bool) { + if x == nil { + return "", false + } + anchors := x.byFile[file] + if len(anchors) == 0 { + return "", false + } + return anchors[0].ptr, true // byFile is sorted by line +} + +// AnchoredPointers returns every pointer that has an anchor of its OWN (not one +// inherited from an ancestor). Backs the spec pane's gutter: the caller maps +// each to its rendered line, marking the nodes whose source position is exact. +func (x *SourceIndex) AnchoredPointers() []string { + if x == nil { + return nil + } + out := make([]string, 0, len(x.fwd)) + for ptr := range x.fwd { + out = append(out, ptr) + } + + return out +} + +// AnchorLines returns the 1-based source lines in file that carry an anchor, +// i.e. the lines that produced a spec node. Backs the source viewer's gutter. +func (x *SourceIndex) AnchorLines(file string) map[int]bool { + if x == nil { + return nil + } + anchors := x.byFile[file] + if len(anchors) == 0 { + return nil + } + out := make(map[int]bool, len(anchors)) + for _, a := range anchors { + out[a.line] = true + } + + return out +} + +// PointerAt returns the pointer of the nearest anchor at or above (file, line) — +// the spec node enclosing that source line. line is 1-based (token.Position.Line). +// The bool is false when the file holds no anchors or line precedes the first. +func (x *SourceIndex) PointerAt(file string, line int) (string, bool) { + if x == nil { + return "", false + } + anchors := x.byFile[file] + if len(anchors) == 0 { + return "", false + } + // greatest anchor line <= line + i := sort.Search(len(anchors), func(i int) bool { return anchors[i].line > line }) - 1 + if i < 0 { + return "", false + } + return anchors[i].ptr, true +} diff --git a/cmd/genspec-tui/internal/ux/index/sourceindex_test.go b/cmd/genspec-tui/internal/ux/index/sourceindex_test.go new file mode 100644 index 00000000..d97c9eb5 --- /dev/null +++ b/cmd/genspec-tui/internal/ux/index/sourceindex_test.go @@ -0,0 +1,130 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package index + +import ( + "go/token" + "testing" + + "github.com/go-openapi/codescan/internal/scanner" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +func srcPos(file string, line int) token.Position { + return token.Position{Filename: file, Line: line} +} + +func TestSourceIndex_PositionFor(t *testing.T) { + idx := BuildSourceIndex([]scanner.Provenance{ + {Pointer: "/definitions/User", Pos: srcPos("user.go", 10)}, + {Pointer: "/definitions/User/properties/email", Pos: srcPos("user.go", 14)}, + {Pointer: "/paths/~1pets/get", Pos: srcPos("api.go", 3)}, + }) + + t.Run("exact hit", func(t *testing.T) { + p, ok := idx.PositionFor("/definitions/User/properties/email") + require.True(t, ok) + assert.Equal(t, "user.go", p.Filename) + assert.Equal(t, 14, p.Line) + }) + + t.Run("nearest anchored ancestor", func(t *testing.T) { + // A finer node with no anchor of its own resolves to the closest + // anchored ancestor — here the property, then the definition. + p, ok := idx.PositionFor("/definitions/User/properties/email/format") + require.True(t, ok) + assert.Equal(t, 14, p.Line, "should resolve up to the property anchor") + + p, ok = idx.PositionFor("/definitions/User/required/0") + require.True(t, ok) + assert.Equal(t, 10, p.Line, "should resolve up to the definition anchor") + }) + + t.Run("no anchor at or above", func(t *testing.T) { + _, ok := idx.PositionFor("/swagger") + assert.False(t, ok) + _, ok = idx.PositionFor("") + assert.False(t, ok) + }) + + t.Run("nil index", func(t *testing.T) { + var nilIdx *SourceIndex + _, ok := nilIdx.PositionFor("/definitions/User") + assert.False(t, ok) + assert.Equal(t, 0, nilIdx.Len()) + }) +} + +func TestSourceIndex_PointerAt(t *testing.T) { + idx := BuildSourceIndex([]scanner.Provenance{ + {Pointer: "/definitions/User", Pos: srcPos("user.go", 10)}, + {Pointer: "/definitions/User/properties/email", Pos: srcPos("user.go", 14)}, + {Pointer: "/definitions/User/properties/name", Pos: srcPos("user.go", 12)}, + {Pointer: "/paths/~1pets/get", Pos: srcPos("api.go", 3)}, + }) + + t.Run("nearest enclosing anchor", func(t *testing.T) { + // A line inside the email field (anchored at 14) but below it resolves + // to that field; a line between name (12) and email (14) resolves to name. + ptr, ok := idx.PointerAt("user.go", 15) + require.True(t, ok) + assert.Equal(t, "/definitions/User/properties/email", ptr) + + ptr, ok = idx.PointerAt("user.go", 13) + require.True(t, ok) + assert.Equal(t, "/definitions/User/properties/name", ptr) + + ptr, ok = idx.PointerAt("user.go", 10) + require.True(t, ok) + assert.Equal(t, "/definitions/User", ptr, "exact line lands on the definition") + }) + + t.Run("line above the first anchor", func(t *testing.T) { + _, ok := idx.PointerAt("user.go", 1) + assert.False(t, ok) + }) + + t.Run("unknown file", func(t *testing.T) { + _, ok := idx.PointerAt("other.go", 5) + assert.False(t, ok) + }) + + t.Run("per-file isolation", func(t *testing.T) { + ptr, ok := idx.PointerAt("api.go", 9) + require.True(t, ok) + assert.Equal(t, "/paths/~1pets/get", ptr) + }) +} + +func TestSourceIndex_FirstAnchor(t *testing.T) { + idx := BuildSourceIndex([]scanner.Provenance{ + {Pointer: "/definitions/User/properties/email", Pos: srcPos("user.go", 14)}, + {Pointer: "/definitions/User", Pos: srcPos("user.go", 10)}, + {Pointer: "/paths/~1pets/get", Pos: srcPos("api.go", 3)}, + }) + + ptr, ok := idx.FirstAnchor("user.go") + require.True(t, ok) + assert.Equal(t, "/definitions/User", ptr, "earliest line in the file wins") + + _, ok = idx.FirstAnchor("missing.go") + assert.False(t, ok) + + var nilIdx *SourceIndex + _, ok = nilIdx.FirstAnchor("user.go") + assert.False(t, ok) +} + +func TestSourceIndex_LastWins(t *testing.T) { + // A pointer recorded twice (node rewritten during the build) keeps the last + // position — upsert semantics. + idx := BuildSourceIndex([]scanner.Provenance{ + {Pointer: "/definitions/X", Pos: srcPos("a.go", 1)}, + {Pointer: "/definitions/X", Pos: srcPos("a.go", 7)}, + }) + p, ok := idx.PositionFor("/definitions/X") + require.True(t, ok) + assert.Equal(t, 7, p.Line) +} diff --git a/cmd/genspec-tui/internal/ux/index/specindex.go b/cmd/genspec-tui/internal/ux/index/specindex.go new file mode 100644 index 00000000..d707401f --- /dev/null +++ b/cmd/genspec-tui/internal/ux/index/specindex.go @@ -0,0 +1,114 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package index + +import ( + "sort" + + lexer "github.com/go-openapi/core/json/lexers/default-lexer" + yamllexer "github.com/go-openapi/core/json/lexers/yaml-lexer" +) + +// SpecIndex maps between rendered-spec lines and the RFC 6901 JSON pointer of +// the spec node shown on each line. It is the spec-side half of the cross-ref +// linker (design §4): line ↔ pointer, built fresh from the exact bytes the spec +// pane displays. Lines are 0-based (matching the viewport's strings.Split +// addressing); the same structure also anchors spec remarks. +type SpecIndex struct { + line2ptr map[int]string + ptr2line map[string]int + lines []int // sorted keys of line2ptr, for nearest-preceding lookup +} + +// NewSpecIndex finalizes the maps into a SpecIndex with sorted line keys. +func NewSpecIndex(line2ptr map[int]string, ptr2line map[string]int) *SpecIndex { + lines := make([]int, 0, len(line2ptr)) + for l := range line2ptr { + lines = append(lines, l) + } + sort.Ints(lines) + return &SpecIndex{line2ptr: line2ptr, ptr2line: ptr2line, lines: lines} +} + +// Indexes are the per-render products of a single lexer walk over the rendered +// spec: where each node is, where each $ref points, and what every token is. +// They are grouped because they share the walk — adding a fourth should not +// mean a fourth traversal of the same bytes. +type Indexes struct { + Spec *SpecIndex + Refs *RefIndex + Highlight *HighlightIndex +} + +// BuildJSONIndex builds the per-render indexes from indented JSON bytes in ONE +// lexer pass. +// +// The lexer reports, per token, its JSON pointer (RFC 6901 escaping handled for +// us) and its 1-based source line. The first token to report a pointer is the +// line that member is declared on; later repeats (its value, its closing +// delimiter) are the same node seen again. Reads the bytes the pane renders, so +// the ordered keys of spec.Swagger's MarshalJSON are preserved. +func BuildJSONIndex(b []byte) Indexes { + acc := newIndexAccum() + + lex := lexer.NewVerbatimWithBytes(b, lexer.WithJSONPointer(true)) + for tok := range lex.Tokens() { + line := lex.Line() - 1 + acc.add(lex.JSONPointer().String(), line, tok.IsKey(), tok.Value()) + acc.addSpan(line, lex.Column(), tok.Kind()) + } + + return acc.finish() +} + +// BuildYAMLIndex is BuildJSONIndex over the YAML render. The YAML lexer emits +// the same logical token stream with the same RFC 6901 pointer escaping, so the +// indexes built from either render are interchangeable — only the lines differ. +func BuildYAMLIndex(b []byte) Indexes { + acc := newIndexAccum() + + lex := yamllexer.NewWithBytes(b, yamllexer.WithJSONPointer(true)) + for tok := range lex.Tokens() { + line := lex.Line() - 1 + acc.add(lex.JSONPointer().String(), line, tok.IsKey(), tok.Value()) + acc.addSpan(line, lex.Column(), tok.Kind()) + } + + return acc.finish() +} + +// PointerAt returns the JSON pointer of the node at line, or the nearest member +// line above it when line itself carries no pointer (e.g. a closing brace). The +// bool is false only for an empty index or a line above the first member. +func (x *SpecIndex) PointerAt(line int) (string, bool) { + if x == nil || len(x.lines) == 0 { + return "", false + } + if p, ok := x.line2ptr[line]; ok { + return p, true + } + // greatest indexed line <= line + i := sort.SearchInts(x.lines, line+1) - 1 + if i < 0 { + return "", false + } + return x.line2ptr[x.lines[i]], true +} + +// LineForPointer returns the 0-based line where pointer is rendered. +func (x *SpecIndex) LineForPointer(ptr string) (int, bool) { + if x == nil { + return 0, false + } + l, ok := x.ptr2line[ptr] + return l, ok +} + +// Len reports how many pointers the index holds (0 for a nil index). +func (x *SpecIndex) Len() int { + if x == nil { + return 0 + } + return len(x.ptr2line) +} diff --git a/cmd/genspec-tui/internal/ux/index/specindex_test.go b/cmd/genspec-tui/internal/ux/index/specindex_test.go new file mode 100644 index 00000000..21cb58b2 --- /dev/null +++ b/cmd/genspec-tui/internal/ux/index/specindex_test.go @@ -0,0 +1,129 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package index + +import "testing" + +// specJSON is a small rendered-spec sample exercising the cases that matter: +// nested objects, an array element, and a key needing RFC 6901 escaping +// (`/pets` → `~1pets`). Indented exactly as json.MarshalIndent renders. +const specJSON = `{ + "definitions": { + "User": { + "properties": { + "email": { + "type": "string" + } + }, + "required": [ + "email" + ] + } + }, + "paths": { + "/pets": { + "get": { + "operationId": "listPets" + } + } + } +}` + +func TestBuildJSONIndex(t *testing.T) { + idx := BuildJSONIndex([]byte(specJSON)).Spec + + // pointer → 0-based line (count lines in specJSON above). + want := map[string]int{ + "/definitions": 1, + "/definitions/User": 2, + "/definitions/User/properties": 3, + "/definitions/User/properties/email": 4, + "/definitions/User/properties/email/type": 5, + "/definitions/User/required": 8, + "/definitions/User/required/0": 9, + "/paths": 13, + "/paths/~1pets": 14, // escaped key + "/paths/~1pets/get": 15, + "/paths/~1pets/get/operationId": 16, + } + for ptr, line := range want { + got, ok := idx.LineForPointer(ptr) + if !ok { + t.Errorf("pointer %q missing from index", ptr) + continue + } + if got != line { + t.Errorf("pointer %q: line = %d, want %d", ptr, got, line) + } + } + + // line → pointer round-trips for a representative member line. + if p, ok := idx.PointerAt(4); !ok || p != "/definitions/User/properties/email" { + t.Errorf("PointerAt(4) = %q,%v; want the email property", p, ok) + } + + // A closing-brace line (7: ` },`) carries no member; PointerAt resolves + // to the nearest preceding member line (the email type at 5). + if p, ok := idx.PointerAt(7); !ok || p != "/definitions/User/properties/email/type" { + t.Errorf("PointerAt(7) nearest-preceding = %q,%v", p, ok) + } +} + +// specYAML mirrors specJSON's shape (keys in a fixed order so line numbers are +// stable); the index must produce the same pointers as the JSON side. +const specYAML = `definitions: + User: + properties: + email: + type: string + required: + - email +paths: + /pets: + get: + operationId: listPets +` + +func TestBuildYAMLIndex(t *testing.T) { + idx := BuildYAMLIndex([]byte(specYAML)).Spec + + want := map[string]int{ + "/definitions": 0, + "/definitions/User": 1, + "/definitions/User/properties": 2, + "/definitions/User/properties/email": 3, + "/definitions/User/properties/email/type": 4, + "/definitions/User/required": 5, + "/definitions/User/required/0": 6, + "/paths": 7, + "/paths/~1pets": 8, // escaped key, same as JSON + "/paths/~1pets/get": 9, + "/paths/~1pets/get/operationId": 10, + } + for ptr, line := range want { + got, ok := idx.LineForPointer(ptr) + if !ok { + t.Errorf("pointer %q missing from YAML index", ptr) + continue + } + if got != line { + t.Errorf("pointer %q: line = %d, want %d", ptr, got, line) + } + } +} + +func TestSpecIndexEmptyAndNil(t *testing.T) { + var nilIdx *SpecIndex + if _, ok := nilIdx.PointerAt(3); ok { + t.Error("nil index PointerAt should report not-found") + } + if nilIdx.Len() != 0 { + t.Error("nil index Len should be 0") + } + + empty := BuildJSONIndex([]byte(`{}`)).Spec + if _, ok := empty.PointerAt(0); ok { + t.Error("empty object should index no pointers") + } +} diff --git a/cmd/genspec-tui/internal/ux/key/bindings.go b/cmd/genspec-tui/internal/ux/key/bindings.go new file mode 100644 index 00000000..578c0879 --- /dev/null +++ b/cmd/genspec-tui/internal/ux/key/bindings.go @@ -0,0 +1,70 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package key normalizes tea.KeyMsg values into a small enum of named +// bindings, so the model dispatches on a plain string switch rather than a +// key-binding library. Mirrors the convention in fredbi/git-janitor. +package key + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" +) + +// Binding is a lowercased key descriptor (e.g. "ctrl+j", "tab", "k"). +type Binding string + +const ( + CtrlC Binding = "ctrl+c" + CtrlQ Binding = "ctrl+q" + CtrlJ Binding = "ctrl+j" + CtrlY Binding = "ctrl+y" + Tab Binding = "tab" + ShiftTab Binding = "shift+tab" + Up Binding = "up" + Down Binding = "down" + Left Binding = "left" + Right Binding = "right" + J Binding = "j" + K Binding = "k" + H Binding = "h" + L Binding = "l" + C Binding = "c" + R Binding = "r" + O Binding = "o" + G Binding = "g" + F Binding = "f" + I Binding = "i" + PgUp Binding = "pgup" + PgDown Binding = "pgdown" + Home Binding = "home" + End Binding = "end" + Space Binding = " " + Question Binding = "?" + Esc Binding = "esc" + Enter Binding = "enter" + + // F3 steps to the next reference of the definition under the spec cursor. + F3 Binding = "f3" + + // ShiftF3 steps to the PREVIOUS reference. + // + // bubbletea v1's Key carries no Shift modifier, and the xterm family maps + // shift+F1..F12 onto F13..F24 — so shift+F3 reaches us as F15. This is + // terminal-dependent: a terminal that emits nothing distinguishable for + // shift+F3 simply has no prev key. + ShiftF3 Binding = "f15" + + // ShiftF3Named is the literal spelling, accepted so a terminal (or a future + // bubbletea) that reports the modifier directly also works. + ShiftF3Named Binding = "shift+f3" +) + +// MsgBinding normalizes a key message to a Binding. +func MsgBinding(msg tea.KeyMsg) Binding { + return Binding(strings.ToLower(msg.String())) +} + +// Quit reports whether the binding requests application exit. +func (b Binding) Quit() bool { return b == CtrlC || b == CtrlQ } diff --git a/cmd/genspec-tui/internal/ux/main_test.go b/cmd/genspec-tui/internal/ux/main_test.go new file mode 100644 index 00000000..55216c13 --- /dev/null +++ b/cmd/genspec-tui/internal/ux/main_test.go @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package ux + +import ( + "os" + "testing" + + "github.com/charmbracelet/lipgloss" + "github.com/muesli/termenv" +) + +// TestMain forces a colour profile for the whole package's tests. +// +// lipgloss degrades to plain text off a TTY, which `go test` never is, so +// without this every style renders identically and any assertion about styling +// passes just as happily against a pane that applied none. The panels package +// needs the same thing for the same reason. +func TestMain(m *testing.M) { + lipgloss.SetColorProfile(termenv.TrueColor) + os.Exit(m.Run()) +} diff --git a/cmd/genspec-tui/internal/ux/model.go b/cmd/genspec-tui/internal/ux/model.go new file mode 100644 index 00000000..7d097299 --- /dev/null +++ b/cmd/genspec-tui/internal/ux/model.go @@ -0,0 +1,1805 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package ux is the bubbletea front-end for genspec-tui: a single root Model +// composing a header line, three panels (source tree, spec, diagnostics), and +// a status/help line. Structure borrows from fredbi/git-janitor — one root +// model owning panel values, an enum-based key dispatch, mouse focus/scroll, +// and a recalcLayout that distributes the terminal size across panels. +package ux + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/charmbracelet/bubbles/spinner" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/go-openapi/codescan" + "github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux/gadgets" + "github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux/index" + "github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux/key" + "github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux/panels" + "github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux/theme" + "github.com/go-openapi/codescan/internal/parsers/grammar" +) + +// headerH / statusH are the single-line chrome rows reserved top and bottom. +const ( + headerH = 1 + statusH = 1 +) + +// bufferTabWidth is how many spaces bubbles/textarea substitutes for a tab when +// a file is loaded into it. Not a preference of ours — a property of the widget +// that every file coordinate has to be translated through. +const bufferTabWidth = 4 + +// noticeTTL is how long a transient status notice (e.g. "copied to +// clipboard") stays on the status line before it clears. +const noticeTTL = 2 * time.Second + +// debounceDelay coalesces a burst of file-change events (an editor save often +// fires several) into a single rescan. +const debounceDelay = 300 * time.Millisecond + +// copyResultMsg is delivered after an async clipboard copy completes. +type copyResultMsg struct{ err error } + +// clearNoticeMsg clears the transient status notice. +type clearNoticeMsg struct{} + +// fsEventMsg signals that the watcher saw a relevant source change. +type fsEventMsg struct{} + +// debounceMsg fires after the quiet period; gen guards against stale timers. +type debounceMsg struct{ gen int } + +type pane int + +const ( + paneTree pane = iota + paneSpec + paneDiag + paneCount +) + +// leftMode is what the left pane shows: the source tree or a file's content. +type leftMode int + +const ( + modeBrowse leftMode = iota + modeView +) + +// followMode is the cross-ref auto-follow state: off, or one pane driving the +// other. The driver keeps focus; the follower mirrors on every cursor move +// (syncFollowIfActive runs after each key/scroll). `f` toggles it; any focus +// change or edit exits it. +type followMode int + +const ( + followOff followMode = iota + followSpec // spec drives, the source pane follows + followSource // the source pane drives, the spec follows + followDiag // the diagnostics pane drives, the source pane follows +) + +// Cross-ref outcome descriptions. A link can fail for genuinely different +// reasons, and conflating them sends the user hunting for a bug that isn't +// there: a node with no anchored ancestor was never produced from code (design +// §3.8 — an InputSpec overlay node legitimately has no origin), whereas a node +// that resolved but isn't rendered is simply outside the active JSON/YAML view. +// Both are first-class answers, not errors, so every link helper returns one of +// these whether or not the follower moved. +const ( + noNodeDesc = "(no node here)" + noFileDesc = "(no file open)" + noAnchorDesc = "no spec node anchored at or above this line" + noProvenanceDesc = "no provenance from the last scan" + noSourceSuffix = " · no source (not produced from code)" + notRenderedSuffix = " · not rendered in this view" +) + +// Option groups, in the order the overlay shows them. Seventeen flat rows is a +// wall; these are the same divisions CLAUDE.md already uses to describe the +// knobs, so the overlay and the docs agree on where a setting lives. +const ( + groupScope = "discovery & scope" + groupRefs = "$ref & composition" + groupNaming = "naming" + groupDocs = "docs & comments" + groupTypes = "types & extensions" + + // optionGroupCount is how many of the above appear, for sizing only. + optionGroupCount = 5 +) + +// optDep records that a toggle only bites when another one holds a particular +// value. Several codescan knobs are modifiers rather than independent switches +// — PruneUnusedModels does nothing without ScanModels, EmitXGoType does nothing +// while SkipExtensions suppresses every x-go-* extension — and an overlay that +// let you tick them without saying so would quietly lie about what it did. +type optDep struct { + ptr *bool + on bool // the value ptr must hold for the dependent toggle to matter + label string // the dependency's name, for the explanatory suffix +} + +// satisfied reports whether the dependency currently holds. +func (d *optDep) satisfied() bool { return d == nil || *d.ptr == d.on } + +// note renders the "…but only when X" suffix shown while the dependency is unmet. +func (d *optDep) note() string { + if d.on { + return " (needs " + d.label + ")" + } + + return " (moot: " + d.label + ")" +} + +// optToggle binds an options-popup row to a boolean field of the scan config, +// with a short human description (the field names alone are cryptic) and the +// section it belongs to. Rows are stored flat and grouped at render time, so +// cursor movement stays a plain index and headers can never be landed on. +type optToggle struct { + group string + label string + desc string + ptr *bool + dep *optDep +} + +// Model is the root bubbletea model. +type Model struct { + cfg codescan.Options + width, height int + ready bool + focused pane + notice string + + scanning bool + spin spinner.Model + numPaths int + numDefs int + lastElapsed time.Duration + + searching bool + searchInput textinput.Model + + helpOpen bool + helpScroll int + + optionsOpen bool + optCursor int + optDirty bool + optToggles []optToggle + + specJSON string + specYAML string + specIndex *index.SpecIndex // rendered-line ↔ JSON-pointer map for the active format + refIndex *index.RefIndex // $ref sites in the active render (find-references / go-to-definition) + srcIndex *index.SourceIndex // JSON-pointer ↔ Go source position (cross-ref linker) + diags []grammar.Diagnostic + scanErr error // hard error from the last codescan.Run, shown in the diag pane + diagCursor int // selected diagnostic, for diagnostic→source navigation + + watch *watcher + watchCh <-chan struct{} + debounceGen int + + // layout regions, recomputed by recalcLayout and reused for hit-testing. + leftW, topH, diagH int + + leftMode leftMode + currentFile string + currentSource string // the open file as READ, whose coordinates diagnostics use + + follow followMode + followTarget string // human-readable resolved target, for the nav status badge + + // Find-references cycle state (F3 / shift+F3). Valid only for the CURRENT + // render, so refreshSpec resets it. + refAnchor string // the definition pointer whose uses are being cycled + refSites []index.RefSite // its reference sites, ordered by rendered line + refCursor int // which site we are parked on + refStatus string // persistent status line while a cycle is active + + tree panels.Tree + fileView panels.FileView + spec panels.Spec + diag panels.Diagnostics +} + +// New builds the root model around a ready-made scan config; the source tree +// browses cfg.WorkDir. Taking the whole Options rather than a handful of +// arguments means a new CLI flag needs no signature change here — and the +// boolean knobs the overlay drives are the same struct the caller filled in. +// +// A file watcher is started best-effort — if it can't initialize, live reload +// is simply unavailable and the user falls back to `r` (manual rescan). +func New(cfg codescan.Options) *Model { + sp := spinner.New() + sp.Spinner = spinner.Dot + + si := textinput.New() + si.Prompt = "/" + si.Placeholder = "search spec" + + m := &Model{ + cfg: cfg, + focused: paneTree, + spin: sp, + searchInput: si, + tree: panels.NewTree(cfg.WorkDir), + fileView: panels.NewFileView(), + spec: panels.NewSpec(), + diag: panels.NewDiagnostics(), + } + // Options-popup rows bind to the scan-config booleans (pointers into + // m.cfg stay valid — m is heap-allocated). EVERY exported bool on + // codescan.Options belongs here; TestOptions_OverlayCoversEveryBoolKnob + // fails when one is added without a row, which is how this list fell eleven + // knobs behind the v0.36 feature streak before anyone noticed. + // Aliased because two rows depend on them (and `scanModels` is already the + // New parameter, so these need distinct names). + depScanModels, depSkipExtensions := &m.cfg.ScanModels, &m.cfg.SkipExtensions + m.optToggles = []optToggle{ + // Discovery & scope. + {groupScope, "ScanModels", "also emit swagger:model definitions", depScanModels, nil}, + { + groupScope, "PruneUnusedModels", "drop models nothing references", &m.cfg.PruneUnusedModels, + &optDep{depScanModels, true, "ScanModels"}, + }, + {groupScope, "ExcludeDeps", "skip packages outside the module", &m.cfg.ExcludeDeps, nil}, + + // $ref & composition. + {groupRefs, "RefAliases", "$ref aliases instead of expanding", &m.cfg.RefAliases, nil}, + {groupRefs, "TransparentAliases", "aliases never become definitions", &m.cfg.TransparentAliases, nil}, + {groupRefs, "EmitRefSiblings", "description beside $ref, not allOf", &m.cfg.EmitRefSiblings, nil}, + { + groupRefs, "SkipAllOfCompounding", "never wrap in allOf; drops validations", + &m.cfg.SkipAllOfCompounding, nil, + }, + {groupRefs, "DefaultAllOfForEmbeds", "plain embeds compose via allOf", &m.cfg.DefaultAllOfForEmbeds, nil}, + + // Naming. + {groupNaming, "EmitHierarchicalNames", "nest colliding names, not concats", &m.cfg.EmitHierarchicalNames, nil}, + {groupNaming, "SkipJSONifyInterfaceMethods", "interface methods keep their names", &m.cfg.SkipJSONifyInterfaceMethods, nil}, + + // Docs & comments. + {groupDocs, "SingleLineCommentAsDescription", "one-line doc is description, not title", &m.cfg.SingleLineCommentAsDescription, nil}, + {groupDocs, "AfterDeclComments", "annotations inside or after a decl", &m.cfg.AfterDeclComments, nil}, + {groupDocs, "CleanGoDoc", "strip godoc-only syntax from prose", &m.cfg.CleanGoDoc, nil}, + {groupDocs, "SkipEnumDescriptions", "enum names only on x-go-enum-desc", &m.cfg.SkipEnumDescriptions, nil}, + + // Types & extensions. + {groupTypes, "SetXNullableForPointers", "pointer fields get x-nullable", &m.cfg.SetXNullableForPointers, nil}, + {groupTypes, "SkipExtensions", "omit x-go-* vendor extensions", depSkipExtensions, nil}, + { + groupTypes, "EmitXGoType", "stamp x-go-type on definitions", &m.cfg.EmitXGoType, + &optDep{depSkipExtensions, false, "SkipExtensions"}, + }, + } + if w, err := newWatcher(cfg.WorkDir); err == nil { + m.watch = w + m.watchCh = w.events + } + return m +} + +// Close releases the file watcher. Call after the program exits. +func (m *Model) Close() { + if m.watch != nil { + _ = m.watch.Close() + } +} + +// Init implements tea.Model: kick off the initial whole-scope scan and, if a +// watcher is available, begin listening for source changes. +func (m *Model) Init() tea.Cmd { + cmds := []tea.Cmd{m.startScan()} + if m.watchCh != nil { + cmds = append(cmds, waitForFS(m.watchCh)) + } + return tea.Batch(cmds...) +} + +// Update implements tea.Model. +func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width, m.height = msg.Width, msg.Height + m.ready = true + m.recalcLayout() + return m, nil + + case tea.KeyMsg: + model, cmd := m.handleKey(msg) + m.syncFollowIfActive() // re-mirror the follower after a driver move + return model, cmd + + case tea.MouseMsg: + model, cmd := m.handleMouse(msg) + m.syncFollowIfActive() + return model, cmd + + case spinner.TickMsg: + if !m.scanning { + return m, nil + } + var cmd tea.Cmd + m.spin, cmd = m.spin.Update(msg) + return m, cmd + + case scanResultMsg: + m.scanning = false + m.specJSON, m.specYAML = msg.json, msg.yaml + m.numPaths, m.numDefs = msg.paths, msg.defs + m.lastElapsed = msg.elapsed + m.diags = msg.diags + m.scanErr = msg.err + m.diagCursor = clampInt(m.diagCursor, 0, max(len(m.diags)-1, 0)) + m.srcIndex = index.BuildSourceIndex(msg.provenance) + m.applyScan() + m.syncFollowIfActive() // refresh the follower against the rebuilt spec + return m, nil + + case fsEventMsg: + // A change arrived: start (restart) the debounce window and keep + // listening for the next event. + m.debounceGen++ + return m, tea.Batch(debounceCmd(m.debounceGen), waitForFS(m.watchCh)) + + case debounceMsg: + // Rescan only if no newer change arrived during the quiet period. + if msg.gen == m.debounceGen { + return m, m.startScan() + } + return m, nil + + case copyResultMsg: + if msg.err != nil { + m.notice = "clipboard error: " + msg.err.Error() + } else { + m.notice = "copied to clipboard" + } + return m, clearNoticeAfter(noticeTTL) + + case clearNoticeMsg: + m.notice = "" + return m, nil + } + + return m, m.updateFocused(msg) +} + +// View implements tea.Model. +func (m *Model) View() string { + if !m.ready { + return "loading…" + } + + if m.optionsOpen { + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, m.optionsView()) + } + if m.helpOpen { + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, m.helpView()) + } + + top := lipgloss.JoinHorizontal( + lipgloss.Top, + m.leftView(m.focused == paneTree), + m.spec.View(m.focused == paneSpec), + ) + + return m.headerLine() + "\n" + + top + "\n" + + m.diag.View(m.focused == paneDiag) + "\n" + + m.statusLine() +} + +// startScan marks a scan in flight and returns the scan command, starting the +// spinner only when one isn't already running (avoids stacking tick loops). +func (m *Model) startScan() tea.Cmd { + already := m.scanning + m.scanning = true + scan := runScan(m.cfg) + if already { + return scan + } + return tea.Batch(scan, m.spin.Tick) +} + +func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + // Modal/input modes capture all keys until dismissed. + if m.optionsOpen { + return m.handleOptionsKey(msg) + } + if m.helpOpen { + return m.handleHelpKey(msg) + } + if m.searching { + return m.handleSearchKey(msg) + } + // The editor captures everything except a handful of app keys. + if m.leftMode == modeView && m.focused == paneTree && m.fileView.Editing() { + return m.handleEditKey(msg) + } + + // Then the focused pane gets first refusal; whatever it declines falls + // through to the global bindings below. + if cmd, handled := m.routePaneKey(msg); handled { + return m, cmd + } + + if mdl, cmd, handled := m.handleSearchControl(msg); handled { + return mdl, cmd + } + + switch key.MsgBinding(msg) { + case key.CtrlC, key.CtrlQ: + return m, tea.Quit + case key.Tab: + m.focused = (m.focused + 1) % paneCount + return m, m.syncEditFocus() + case key.ShiftTab: + m.focused = (m.focused + paneCount - 1) % paneCount + return m, m.syncEditFocus() + case key.CtrlJ: + m.setSpecFormat("JSON") + return m, nil + case key.CtrlY: + m.setSpecFormat("YAML") + return m, nil + case key.R: + return m, m.startScan() + case key.H, key.Question: + m.openHelp() + return m, nil + case key.O: + m.exitFollow() + m.resetRefCycle() + m.optionsOpen = true + m.optDirty = false + return m, nil + case key.Enter: + // Enter on a file (in browse mode) opens it in the editor. + // Dirs fall through to the tree (expand/collapse). + if m.focused == paneTree && m.leftMode == modeBrowse { + if path, isDir, ok := m.tree.Selection(); ok && !isDir { + return m, m.openFile(path) + } + } + return m, m.updateFocused(msg) + case key.G: + // Jump the spec to the first node the selected source file produced + // (position-backed locate). + if path, isDir, ok := m.tree.Selection(); ok && !isDir { + return m, m.locateInSpec(path) + } + return m, nil + case key.F: + // Toggle spec-driven follow mode: as the spec scrolls, the source pane + // mirrors the node at the top of the viewport (spec→source). + if m.focused == paneSpec { + m.toggleFollow(followSpec) + } + return m, nil + case key.C: + return m, m.copyFocused() + case key.Esc: + if m.follow != followOff { + m.exitFollow() + return m, nil + } + m.resetRefCycle() + m.spec.ClearSearch() + return m, nil + } + + return m, m.updateFocused(msg) +} + +// handleSearchControl handles the case-sensitive search keys (`/` opens search, +// `n`/`N` step matches) that MsgBinding would lowercase. Returns handled=false +// for anything else. +func (m *Model) handleSearchControl(msg tea.KeyMsg) (tea.Model, tea.Cmd, bool) { + switch msg.String() { + case "/": + mdl, cmd := m.enterSearch() + return mdl, cmd, true + case "n": + if _, total := m.spec.MatchInfo(); total > 0 { + m.spec.Step(+1) + return m, nil, true + } + case "N": + if _, total := m.spec.MatchInfo(); total > 0 { + m.spec.Step(-1) + return m, nil, true + } + } + return m, nil, false +} + +// routePaneKey offers a key to the focused pane's own handler before the +// global bindings see it. Each handler reports handled=false for keys it does +// not own, so a pane shadows only what it genuinely needs — the alternative, +// swallowing everything, is what used to make `/`, `o` and `r` dead while a +// file was open. +func (m *Model) routePaneKey(msg tea.KeyMsg) (tea.Cmd, bool) { + if m.leftMode == modeView && m.focused == paneTree { + return m.handleViewerKey(msg) + } + if m.focused == paneDiag { + return m.handleDiagNav(msg) + } + if cmd, handled := m.handleSpecNav(msg); handled { + return cmd, true + } + + return m.handleRefNav(msg) +} + +// handleSpecNav moves the spec pane's line cursor. The pane is navigable in its +// own right now, so scrolling and "where the user is" are the same thing — +// paging moves the cursor with the view rather than leaving it behind off +// screen, where F3 or Enter would act on a node nobody can see. +func (m *Model) handleSpecNav(msg tea.KeyMsg) (tea.Cmd, bool) { + if m.focused != paneSpec { + return nil, false + } + + page := max(m.topH-3, 1) // the viewport's visible height + switch key.MsgBinding(msg) { + case key.Up, key.K: + m.spec.MoveCursor(-1) + case key.Down, key.J: + m.spec.MoveCursor(+1) + case key.PgUp: + m.spec.MoveCursor(-page) + case key.PgDown: + m.spec.MoveCursor(+page) + case key.Home: + m.spec.SetCursor(0) + case key.End: + m.spec.SetCursor(m.spec.LastLine()) + default: + return nil, false + } + + return nil, true +} + +// handleRefNav handles the Phase-D navigation keys, which belong to the spec +// pane: F3 / shift+F3 cycle the references of the node under the cursor, Enter +// follows the $ref under it. Returns handled=false for every other pane, so +// their own bindings (notably Enter opening a file in the tree) still apply. +func (m *Model) handleRefNav(msg tea.KeyMsg) (tea.Cmd, bool) { + if m.focused != paneSpec { + return nil, false + } + + switch key.MsgBinding(msg) { + case key.F3: + return m.cycleRefs(+1), true + case key.ShiftF3, key.ShiftF3Named: + return m.cycleRefs(-1), true + case key.Enter: + return m.gotoDefinition(), true + } + + return nil, false +} + +// handleDiagNav handles diagnostics-pane selection and follow. Returns +// handled=false for keys it doesn't own, so global bindings still apply. +func (m *Model) handleDiagNav(msg tea.KeyMsg) (tea.Cmd, bool) { + page := m.diag.VisibleRows() + switch key.MsgBinding(msg) { + case key.Up, key.K: + m.moveDiagCursor(-1) + case key.Down, key.J: + m.moveDiagCursor(+1) + case key.PgUp: + m.moveDiagCursor(-page) + case key.PgDown: + m.moveDiagCursor(+page) + case key.Home: + m.moveDiagCursor(-len(m.diags)) + case key.End: + m.moveDiagCursor(len(m.diags)) + case key.F: + // Toggle diagnostics-driven follow mode: as the selection moves, the + // source pane mirrors the selected diagnostic's position. + m.toggleFollow(followDiag) + case key.Enter: + return m.jumpDiagToSource(), true + default: + return nil, false + } + + return nil, true +} + +// jumpDiagToSource opens the selected diagnostic's source line and MOVES FOCUS +// there — the one-shot counterpart to `f` follow mode, matching `g` from the +// tree and Enter in the spec pane. Follow mode is for reading down a list; +// this is for stopping on one and going to work on it. +func (m *Model) jumpDiagToSource() tea.Cmd { + if len(m.diags) == 0 { + return nil + } + + d := m.diags[m.diagCursor] + if !d.Pos.IsValid() || d.Pos.Filename == "" { + m.notice = "(diagnostic carries no position)" + + return clearNoticeAfter(noticeTTL) + } + + m.loadFileQuietly(d.Pos.Filename) + m.fileView.GotoLine(d.Pos.Line - 1) + m.focused = paneTree + m.notice = "→ " + fmt.Sprintf("%s:%d", relTo(m.cfg.WorkDir, d.Pos.Filename), d.Pos.Line) + + return clearNoticeAfter(noticeTTL) +} + +// moveDiagCursor moves the diagnostics selection by delta (clamped) and +// re-renders the pane to highlight and scroll to it. In follow mode the Update +// loop re-mirrors the source pane afterward (syncFollowIfActive). +func (m *Model) moveDiagCursor(delta int) { + if len(m.diags) == 0 { + return + } + m.diagCursor = clampInt(m.diagCursor+delta, 0, len(m.diags)-1) + m.refreshDiagnostics() +} + +// driveDiagToSource mirrors the source follower to the selected diagnostic's +// position, WITHOUT moving focus (the diag pane stays the driver). Returns a +// human-readable target for the status badge; the position rides on the +// diagnostic itself, so no index lookup is needed. +func (m *Model) driveDiagToSource() string { + if len(m.diags) == 0 { + return "(no diagnostics)" + } + d := m.diags[m.diagCursor] + if !d.Pos.IsValid() || d.Pos.Filename == "" { + return "(diagnostic carries no position)" + } + if m.currentFile != d.Pos.Filename { + m.loadFileQuietly(d.Pos.Filename) + } + m.fileView.GotoLine(d.Pos.Line - 1) // follower centres on the target; not focused + return fmt.Sprintf("%s:%d", relTo(m.cfg.WorkDir, d.Pos.Filename), d.Pos.Line) +} + +// handleViewerKey drives the read-only file viewer: move the highlighted nav +// line, follow it to the spec node it produced (`f`), enter the editor (`i`/ +// Enter), or leave back to the tree (Esc). +func (m *Model) handleViewerKey(msg tea.KeyMsg) (tea.Cmd, bool) { + switch key.MsgBinding(msg) { + case key.Up, key.K: + m.fileView.NavUp() + return nil, true + case key.Down, key.J: + m.fileView.NavDown() + return nil, true + case key.PgUp: + m.fileView.ScrollBy(-m.fileView.VisibleRows()) + return nil, true + case key.PgDown: + m.fileView.ScrollBy(m.fileView.VisibleRows()) + return nil, true + case key.Home: + m.fileView.ScrollBy(-m.fileView.LastLine()) + return nil, true + case key.End: + m.fileView.ScrollBy(m.fileView.LastLine()) + return nil, true + case key.F: + // Toggle source-driven follow mode: as the nav line moves, the spec + // mirrors the node it produced (source→spec). + m.toggleFollow(followSource) + return nil, true + case key.I, key.Enter: + return m.fileView.StartEdit(), true + case key.Esc: + if m.follow != followOff { + m.exitFollow() + return nil, true + } + m.leftMode = modeBrowse + return nil, true + } + + // Everything else falls through to the global bindings. The viewer shadows + // only the keys it genuinely owns: swallowing the rest used to disable `/`, + // `o`, `r`, `g` and the format toggle for as long as a file was open, which + // is exactly when you most want to rescan or flip JSON↔YAML. + return nil, false +} + +// handleEditKey routes keys to the file editor while it is focused. A few app +// keys still work: Esc returns to the read-only viewer, Ctrl-S saves, Ctrl-F +// follows the cursor line to the spec, Ctrl-Q quits, Tab moves focus. Everything +// else edits the buffer. +func (m *Model) handleEditKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "esc": + m.fileView.StopEdit() + // The buffer may have moved under the runs computed when it was loaded, + // and stale spans colour by the OLD columns — re-derive from what is + // now there rather than show a plausible lie. + m.refreshSource() + return m, nil + case "ctrl+f": + // One-shot jump from the cursor's source line to the spec node it + // produced, focusing the spec. ctrl+f rather than f because the editor + // owns plain f for typing; follow mode proper runs from the read-only + // viewer. + desc, moved := m.linkSourceToSpec() + if moved { + m.fileView.Blur() + m.focused = paneSpec + m.notice = "→ " + desc + } else { + m.notice = desc + } + return m, clearNoticeAfter(noticeTTL) + case "ctrl+s": + return m, m.saveFile() + case "ctrl+q": + return m, tea.Quit + case "tab": + m.focused = (m.focused + 1) % paneCount + return m, m.syncEditFocus() + case "shift+tab": + m.focused = (m.focused + paneCount - 1) % paneCount + return m, m.syncEditFocus() + } + return m, m.fileView.Update(msg) +} + +// loadFileQuietly loads path into the read-only viewer and switches the left +// pane to view mode WITHOUT changing focus — used by spec-driven follow, where +// the spec keeps focus while the source pane mirrors. A read error is shown in +// the buffer. +func (m *Model) loadFileQuietly(path string) { + m.currentFile = path + content, err := os.ReadFile(path) + if err != nil { + m.currentSource = "" + m.fileView.SetFile(filepath.Base(path), "error reading file: "+err.Error()) + m.fileView.SetSpans(nil) + m.fileView.SetAnchors(nil) + m.leftMode = modeView + + return + } + + m.currentSource = normalizeNewlines(string(content)) + m.fileView.SetFile(relTo(m.cfg.WorkDir, path), m.currentSource) + m.refreshSource() + m.leftMode = modeView +} + +// normalizeNewlines converts CRLF and lone CR to LF. +// +// The editor widget treats a CR as a line break of its own, so a file with +// Windows endings loads as twice as many lines with a blank between each. Line +// numbers below the first CR are then wrong in the buffer while right in the +// file — and the line number is the coordinate the whole cross-reference layer +// is keyed on: provenance anchors, follow mode, go-to-definition, diagnostic +// marks. Normalising on the way in gives the file, the buffer, the indexes and +// the marks one shared notion of what a line is. +// +// Saving therefore writes LF. That is the same bargain the widget already +// imposes on tabs, and it is recorded with it in the module README. +func normalizeNewlines(s string) string { + if !strings.ContainsRune(s, '\r') { + return s + } + + return strings.ReplaceAll(strings.ReplaceAll(s, "\r\n", "\n"), "\r", "\n") +} + +// refreshSource re-derives everything the source pane knows about the open file: +// its lexical runs, the diagnostics landing in it, and the provenance anchors in +// its gutter. +// +// It runs on load, on leaving the editor, and after every rescan. The last is +// what this function exists for — a rescan replaces both the anchors and the +// diagnostics, and before it was wired the open file kept showing the previous +// scan's marks while the pane below it listed the new ones. +func (m *Model) refreshSource() { + if m.currentFile == "" { + return + } + // Tokenize the BUFFER, not the bytes read: textarea rewrites tabs as + // spaces on the way in, so a span computed from the file would sit three + // columns early per leading tab and cut a token in half. + spans := goSpans(m.currentFile, []byte(m.fileView.Value())) + m.fileView.SetSpans(index.MarkDiagnostics(spans, m.sourceMarks())) + m.fileView.SetAnchors(m.srcIndex.AnchorLines(m.currentFile)) +} + +// bufferColumn converts a 1-based BYTE column in a file line — what go/token +// reports — into the 1-based RUNE column of the same character as displayed. +// +// Two conversions in one, and both are needed: multi-byte runes make a byte +// column drift from a rune column, and textarea substitutes four spaces for +// every tab, so leading indentation is wider on screen than in the file. +func bufferColumn(fileLine string, byteCol int) int { + col := 1 + for i, r := range fileLine { + if i >= byteCol-1 { + break + } + if r == '\t' { + col += bufferTabWidth + + continue + } + col++ + } + + return col +} + +// diagKind maps a severity onto the class that paints it. +func diagKind(severity grammar.Severity) theme.SyntaxKind { + switch severity { + case grammar.SeverityError: + return theme.SyntaxDiagError + case grammar.SeverityWarning: + return theme.SyntaxDiagWarn + case grammar.SeverityHint: + return theme.SyntaxDiagHint + default: + return theme.SyntaxDiagHint + } +} + +// goSpans returns the syntax runs for a Go source file, and nil for anything +// else — the tree happily opens go.mod, a golden JSON fixture or a README, and +// a Go tokenizer has nothing true to say about those. +func goSpans(path string, content []byte) map[int][]theme.Span { + if filepath.Ext(path) != ".go" { + return nil + } + + return index.BuildGoHighlight(content).All() +} + +// sourceMarks locates the last scan's diagnostics for the open file in the +// coordinates the pane draws in. +func (m *Model) sourceMarks() []index.DiagMark { + if m.currentSource == "" { + return nil + } + + lines := strings.Split(m.currentSource, "\n") + marks := make([]index.DiagMark, 0, len(m.diags)) + for _, d := range m.diags { + if d.Pos.Filename != m.currentFile || d.Pos.Line < 1 || d.Pos.Line > len(lines) { + continue + } + marks = append(marks, index.DiagMark{ + Line: d.Pos.Line - 1, + Col: bufferColumn(lines[d.Pos.Line-1], d.Pos.Column), + Kind: diagKind(d.Severity), + }) + } + + return marks +} + +// openFile loads path into the read-only viewer and focuses it. The viewer is +// navigable immediately; `i` enters the editor. +func (m *Model) openFile(path string) tea.Cmd { + m.loadFileQuietly(path) + m.focused = paneTree + return nil +} + +// saveFile writes the editor buffer back to disk. The watcher then triggers a +// rescan, so the spec reflects the edit. +func (m *Model) saveFile() tea.Cmd { + if m.currentFile == "" { + return nil + } + if err := os.WriteFile(m.currentFile, []byte(m.fileView.Value()), 0o644); err != nil { //nolint:gosec // user's own source tree + m.notice = "save failed: " + err.Error() + return clearNoticeAfter(noticeTTL) + } + m.fileView.MarkClean() + m.notice = "saved " + relTo(m.cfg.WorkDir, m.currentFile) + return clearNoticeAfter(noticeTTL) +} + +// relTo renders path relative to base when possible, else the base name. +func relTo(base, path string) string { + if rel, err := filepath.Rel(base, path); err == nil && !strings.HasPrefix(rel, "..") { + return rel + } + return filepath.Base(path) +} + +// syncEditFocus focuses the editor only when the left pane is focused in view +// mode AND editing; the read-only viewer needs no textarea focus. Blurs it +// otherwise so a backgrounded editor doesn't keep capturing input. +func (m *Model) syncEditFocus() tea.Cmd { + if m.leftMode == modeView && m.focused == paneTree && m.fileView.Editing() { + return m.fileView.Focus() + } + m.fileView.Blur() + return nil +} + +// openHelp shows the keymap overlay, always from the top: it is opened to look +// something up, not resumed. +func (m *Model) openHelp() { + m.helpOpen = true + m.helpScroll = 0 +} + +// handleHelpKey drives the help overlay: scroll, or dismiss. It swallows +// everything else — the overlay covers the UI, so acting on a key the user +// cannot see the effect of would be worse than ignoring it. +func (m *Model) handleHelpKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch key.MsgBinding(msg) { + case key.Up, key.K: + m.scrollHelp(-1) + case key.Down, key.J: + m.scrollHelp(+1) + case key.PgUp: + m.scrollHelp(-m.helpVisibleRows()) + case key.PgDown: + m.scrollHelp(+m.helpVisibleRows()) + case key.Home: + m.helpScroll = 0 + case key.End: + m.scrollHelp(len(helpLines())) + case key.Esc, key.H, key.Question, key.Enter: + m.helpOpen = false + case key.CtrlQ, key.CtrlC: + return m, tea.Quit + } + + return m, nil +} + +// handleOptionsKey drives the scanner-options modal: move the cursor, toggle a +// boolean with space/enter, and apply-on-close (rescan only if something +// changed). +func (m *Model) handleOptionsKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + last := len(m.optToggles) - 1 + switch key.MsgBinding(msg) { + case key.Up, key.K: + m.optCursor = clampInt(m.optCursor-1, 0, last) + case key.Down, key.J: + m.optCursor = clampInt(m.optCursor+1, 0, last) + case key.PgUp: + m.optCursor = clampInt(m.optCursor-m.optionsVisibleRows(), 0, last) + case key.PgDown: + m.optCursor = clampInt(m.optCursor+m.optionsVisibleRows(), 0, last) + case key.Home: + m.optCursor = 0 + case key.End: + m.optCursor = last + case key.Space, key.Enter: + t := m.optToggles[m.optCursor] + *t.ptr = !*t.ptr + m.optDirty = true + case key.Esc, key.O, key.CtrlQ, key.CtrlC: + m.optionsOpen = false + if m.optDirty { + return m, m.startScan() + } + } + return m, nil +} + +// enterSearch opens the search input over the status line, focusing the spec. +func (m *Model) enterSearch() (tea.Model, tea.Cmd) { + m.exitFollow() + m.resetRefCycle() + m.searching = true + m.focused = paneSpec + m.searchInput.SetValue("") + return m, m.searchInput.Focus() +} + +// handleSearchKey routes keys to the search input; Enter runs the search, Esc +// cancels and clears highlights. +func (m *Model) handleSearchKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.Type { + case tea.KeyEnter: + m.searching = false + m.searchInput.Blur() + q := m.searchInput.Value() + if q == "" { + m.spec.ClearSearch() + return m, nil + } + if n := m.spec.Search(q); n == 0 { + m.notice = "no matches: " + q + return m, clearNoticeAfter(noticeTTL) + } + return m, nil + case tea.KeyEsc: + m.searching = false + m.searchInput.Blur() + m.spec.ClearSearch() + return m, nil + } + + var cmd tea.Cmd + m.searchInput, cmd = m.searchInput.Update(msg) + return m, cmd +} + +// handleMouse focuses the pane under a left-click and scrolls the pane under +// the wheel — no Tab required. +func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { + p, ok := m.paneAt(msg.X, msg.Y) + if !ok { + return m, nil + } + + switch msg.Button { + case tea.MouseButtonWheelUp: + return m, m.scrollPane(p, msg, -1) + case tea.MouseButtonWheelDown: + return m, m.scrollPane(p, msg, +1) + case tea.MouseButtonLeft: + if msg.Action == tea.MouseActionPress { + m.focused = p + return m, m.syncEditFocus() + } + } + return m, nil +} + +// scrollPane scrolls the given pane: the tree moves its cursor; the viewport +// panes handle the wheel event natively. +func (m *Model) scrollPane(p pane, msg tea.MouseMsg, delta int) tea.Cmd { + switch p { + case paneTree: + if m.leftMode == modeView { + if m.fileView.Editing() { + return m.fileView.Update(msg) // textarea handles its own scroll + } + m.fileView.ScrollBy(delta) // read-only viewer moves its nav line + return nil + } + m.tree.ScrollBy(delta) + return nil + case paneSpec: + m.spec.MoveCursor(delta) // the cursor leads; the view follows it + return nil + case paneDiag: + m.moveDiagCursor(delta) // the selection leads; the view follows it + return nil + } + return nil +} + +// paneAt maps terminal coordinates to a pane, using the regions recalcLayout +// stored. Returns false for the header/status chrome rows. +func (m *Model) paneAt(x, y int) (pane, bool) { + topStart := headerH + topEnd := topStart + m.topH + switch { + case y >= topStart && y < topEnd: + if x < m.leftW { + return paneTree, true + } + return paneSpec, true + case y >= topEnd && y < topEnd+m.diagH: + return paneDiag, true + } + return 0, false +} + +// applyScan updates the spec and diagnostics panes from the latest scan. +func (m *Model) applyScan() { + m.refreshSpec() + m.refreshDiagnostics() + m.refreshSource() +} + +// clampInt clamps v to [lo, hi]. +func clampInt(v, lo, hi int) int { + if v < lo { + return lo + } + if v > hi { + return hi + } + return v +} + +// refreshDiagnostics re-renders the diagnostics pane from the stored diagnostics +// and the selection cursor, scrolling the selected diagnostic into view. The +// pane shows any hard error from codescan.Run first, then every +// grammar.Diagnostic the build emitted (colored by severity, paths relative to +// the work dir, the selected row highlighted); a clean scan with no diagnostics +// shows the empty state. +func (m *Model) refreshDiagnostics() { + content, line := renderDiagnostics(m.cfg.WorkDir, m.scanErr, m.diags, m.diagCursor, m.focused == paneDiag) + m.diag.SetContent(content) + if line >= 0 { + m.diag.RevealLine(line) + } +} + +// refreshSpec renders the spec pane from the stored JSON/YAML per the active +// format toggle, and rebuilds the line↔pointer index for the active format +// (the spec-side half of the cross-ref linker, design §4 / build LX-spec-0). +func (m *Model) refreshSpec() { + yamlFmt := m.spec.Format() == "YAML" + body := m.specJSON + if yamlFmt { + body = m.specYAML + } + + // Remember the NODE under the cursor before anything is rebuilt. Every + // re-render renumbers lines — a rescan that gains a definition above you + // shifts everything below it — so carrying the line number across would + // silently move the user to a different node. This is the hot path: every + // save fires it, and live-reload is the tool's whole point. + anchor, anchored := "", false + if m.specIndex != nil { + anchor, anchored = m.specIndex.PointerAt(m.spec.CursorLine()) + } + + // Both indexes and the find-references cycle are per-render: a rescan or a + // format toggle invalidates every line number they hold. + m.resetRefCycle() + if body == "" { + m.specIndex, m.refIndex = nil, nil + m.spec.SetSpans(nil) + m.spec.SetContent("(no spec generated yet)") + m.rebuildGutters() + return + } + built := index.BuildJSONIndex([]byte(body)) + if yamlFmt { + built = index.BuildYAMLIndex([]byte(body)) + } + m.specIndex, m.refIndex = built.Spec, built.Refs + m.spec.SetSpans(built.Highlight.All()) + m.spec.SetContent(body) + if anchored { + m.restoreCursorTo(anchor) + } + m.rebuildGutters() +} + +// restoreCursorTo puts the cursor back on ptr in the freshly built index. +// +// When the node itself is gone — you deleted the type that produced it — the +// walk falls back to its nearest surviving ancestor, so you land in the right +// neighbourhood rather than somewhere arbitrary. When nothing on its path +// survives, the clamped line SetContent already chose stands; there is nothing +// more honest to say. +// +// It scrolls MINIMALLY rather than centring: after a rescan the node has usually +// moved by a line or two and is still on screen, and yanking the viewport on +// every save would be worse than the drift it fixes. An explicit format switch +// recentres afterwards (setSpecFormat), that being a deliberate change of view. +func (m *Model) restoreCursorTo(ptr string) { + for ptr != "" { + if line, ok := m.specIndex.LineForPointer(ptr); ok { + m.spec.SetCursor(line) + return + } + i := strings.LastIndexByte(ptr, '/') + if i < 0 { + return + } + ptr = ptr[:i] + } +} + +// setSpecFormat switches the spec render between JSON and YAML. refreshSpec +// keeps the cursor on the same NODE across the re-render; this additionally +// recentres it, because switching format is a deliberate change of view and the +// node has usually moved far enough that a minimal scroll would leave it pinned +// against an edge. A no-op when the format is already active. +func (m *Model) setSpecFormat(format string) { + if m.spec.Format() == format { + return + } + + m.spec.SetFormat(format) + m.refreshSpec() + m.spec.JumpTo(m.spec.CursorLine()) +} + +// cycleRefs steps through the places the node under the spec cursor is +// referenced (design §6.4 multi-candidate case): dir +1 for the next site, -1 +// for the previous, wrapping. +// +// A cycle continues only while the cursor is still parked on the site the last +// step put it on. Move it and the next F3 re-anchors on the node you are now +// on — which is what makes "F3 repeatedly" walk one definition's uses rather +// than chasing the definition of whatever it last landed on. +func (m *Model) cycleRefs(dir int) tea.Cmd { + onCurrentSite := m.refAnchor != "" && m.refCursor < len(m.refSites) && + m.spec.CursorLine() == m.refSites[m.refCursor].Line + if onCurrentSite { + m.refCursor = (m.refCursor + dir + len(m.refSites)) % len(m.refSites) + } else { + // Drop the old cycle FIRST: if the new node has no references we must + // not leave "ref 1/3 of /definitions/User" on screen while the user is + // looking at something else. + m.resetRefCycle() + if !m.startRefCycle(dir) { + return clearNoticeAfter(noticeTTL) + } + } + + site := m.refSites[m.refCursor] + m.spec.JumpTo(site.Line) + m.refStatus = fmt.Sprintf("ref %d/%d of %s → %s", + m.refCursor+1, len(m.refSites), m.refAnchor, site.Pointer) + + return nil +} + +// startRefCycle begins a new cycle anchored on the node under the spec cursor, +// entering at the first site for a forward step and the last for a backward +// one. Reports false (with a notice explaining why) when there is nothing to +// cycle. +func (m *Model) startRefCycle(dir int) bool { + ptr, ok := m.specIndex.PointerAt(m.spec.CursorLine()) + if !ok { + m.notice = noNodeDesc + + return false + } + anchor, sites := m.refIndex.RefsNear(ptr) + if len(sites) == 0 { + m.notice = "nothing references " + ptr + + return false + } + + m.refAnchor, m.refSites = anchor, sites + m.refCursor = 0 + if dir < 0 { + m.refCursor = len(sites) - 1 + } + + return true +} + +// resetRefCycle drops the find-references state. Called whenever the render it +// was computed against is replaced (rescan, format toggle) or the user moves on. +func (m *Model) resetRefCycle() { + m.refAnchor, m.refSites, m.refCursor = "", nil, 0 + m.refStatus = "" +} + +// gotoDefinition follows the $ref under the spec cursor to the node it points +// at — the inverse of cycleRefs. Only local (`#/…`) refs are followable: the TUI +// renders one spec and is not a $ref resolver, so an external target is +// reported honestly rather than guessed at. +func (m *Model) gotoDefinition() tea.Cmd { + site, ok := m.refIndex.RefAt(m.spec.CursorLine()) + if !ok { + m.notice = "no $ref on this line" + + return clearNoticeAfter(noticeTTL) + } + if !site.Target.Local { + m.notice = "external ref, not in this spec: " + site.Target.Raw + + return clearNoticeAfter(noticeTTL) + } + line, ok := m.specIndex.LineForPointer(site.Target.Pointer) + if !ok { + m.notice = site.Target.Pointer + notRenderedSuffix + + return clearNoticeAfter(noticeTTL) + } + + m.resetRefCycle() + m.spec.JumpTo(line) + m.notice = "→ " + site.Target.Pointer + + return clearNoticeAfter(noticeTTL) +} + +// rebuildGutters recomputes the link markers for both panes (design §6.5): the +// discoverability layer that says which lines actually lead somewhere, now that +// both indexes exist. +// +// The spec side is driven from the SOURCE index rather than by walking the spec: +// only pointers with an anchor of their OWN get a dot. Marking everything that +// merely resolves through an ancestor would dot nearly every line — true, since +// nearest-ancestor resolution almost always finds something, and useless for the +// same reason. A dot therefore means "following this lands exactly here". +func (m *Model) rebuildGutters() { + m.spec.SetGutter(m.specGutter()) + m.fileView.SetAnchors(m.srcIndex.AnchorLines(m.currentFile)) +} + +// specGutter maps rendered lines to their marker: an anchored node, or a +// followable $ref. Returns nil when there is nothing to mark, which keeps the +// gutter column off entirely. +func (m *Model) specGutter() map[int]rune { + if m.specIndex == nil { + return nil + } + + g := make(map[int]rune) + for _, ptr := range m.srcIndex.AnchoredPointers() { + if line, ok := m.specIndex.LineForPointer(ptr); ok { + g[line] = panels.GutterAnchor + } + } + // A $ref line is navigable via Enter even though the $ref member itself is + // never anchored, so it wins the column where the two would collide. + for _, line := range m.refIndex.LocalRefLines() { + g[line] = panels.GutterRef + } + if len(g) == 0 { + return nil + } + + return g +} + +// locateInSpec jumps the spec pane to the first node produced by the given +// source file (position-backed, via the SourceIndex), highlighting it and +// focusing the spec. The exact replacement for the retired name-matching linker. +func (m *Model) locateInSpec(path string) tea.Cmd { + ptr, ok := m.srcIndex.FirstAnchor(path) + if !ok { + m.notice = "no spec node produced by " + filepath.Base(path) + return clearNoticeAfter(noticeTTL) + } + specLine, ok := m.specIndex.LineForPointer(ptr) + if !ok { + m.notice = "node not in the current spec view: " + ptr + return clearNoticeAfter(noticeTTL) + } + m.spec.JumpTo(specLine) + m.focused = paneSpec + m.notice = "→ " + ptr + return clearNoticeAfter(noticeTTL) +} + +// toggleFollow turns the given follow mode on (driving from the current pane) +// or off if it is already active, doing an immediate first sync on entry. +func (m *Model) toggleFollow(mode followMode) { + if m.follow == mode { + m.exitFollow() + return + } + m.resetRefCycle() // follow drives the viewport; the cycle's lines go stale + m.follow = mode + m.syncFollowIfActive() +} + +// exitFollow leaves follow mode and drops the spec follower highlight (the +// source nav line is the viewer's own cursor, so it stays). +func (m *Model) exitFollow() { + if m.follow == followOff { + return + } + m.follow = followOff + m.followTarget = "" +} + +// syncFollowIfActive re-mirrors the follower pane from the driver's current +// position. Runs after every key/scroll. A focus change away from the driver +// (or starting to edit) exits follow mode rather than mirroring stale state. +func (m *Model) syncFollowIfActive() { + switch m.follow { + case followSpec: + if m.focused != paneSpec { + m.exitFollow() + return + } + m.followTarget = m.driveSpecToSource() + case followSource: + if m.focused != paneTree || m.leftMode != modeView || m.fileView.Editing() { + m.exitFollow() + return + } + // The description is meaningful on both outcomes — show it either way + // rather than flattening every miss to one opaque message. + m.followTarget, _ = m.linkSourceToSpec() + case followDiag: + if m.focused != paneDiag { + m.exitFollow() + return + } + m.followTarget = m.driveDiagToSource() + case followOff: + } +} + +// driveSpecToSource mirrors the source follower to the spec node at the top of +// the viewport, WITHOUT moving focus or the spec scroll (the user drives it). +// Returns a human-readable target for the status badge. +func (m *Model) driveSpecToSource() string { + ptr, ok := m.specIndex.PointerAt(m.spec.CursorLine()) + if !ok { + return noNodeDesc + } + pos, ok := m.srcIndex.PositionFor(ptr) + if !ok { + // Hold the follower where it is rather than jumping somewhere wrong + // (design §6.4), and name which of the two misses this is. + if m.srcIndex.Len() == 0 { + return ptr + " · " + noProvenanceDesc + } + return ptr + noSourceSuffix + } + if m.currentFile != pos.Filename { + m.loadFileQuietly(pos.Filename) + } + m.fileView.GotoLine(pos.Line - 1) // follower centres on the target; not focused + return fmt.Sprintf("%s → %s:%d", ptr, relTo(m.cfg.WorkDir, pos.Filename), pos.Line) +} + +// linkSourceToSpec highlights (and scrolls to) the spec node produced by the +// file viewer's current line. No focus change. The description is ALWAYS +// meaningful — callers show it whether or not the follower moved, because +// "this line produced nothing", "nothing was anchored at all" and "the node +// exists but isn't rendered here" are three different answers the user needs +// to tell apart. The bool reports only whether the follower actually moved. +func (m *Model) linkSourceToSpec() (string, bool) { + if m.currentFile == "" { + return noFileDesc, false + } + line := m.fileView.CurrentLine() + 1 // pane rows are 0-based; source lines 1-based + ptr, ok := m.srcIndex.PointerAt(m.currentFile, line) + if !ok { + if m.srcIndex.Len() == 0 { + return noProvenanceDesc, false + } + return noAnchorDesc, false + } + specLine, ok := m.specIndex.LineForPointer(ptr) + if !ok { + return ptr + notRenderedSuffix, false + } + m.spec.JumpTo(specLine) // the follower centres on the produced node + return ptr, true +} + +// copyFocused copies the focused panel's raw content to the clipboard, +// asynchronously (the clipboard tool exec must not block the event loop). +// Returns nil when the focused panel has nothing to copy. +func (m *Model) copyFocused() tea.Cmd { + text := m.focusedContent() + if text == "" { + return nil + } + + return func() tea.Msg { + return copyResultMsg{err: gadgets.CopyToClipboard(context.Background(), text)} + } +} + +// clearNoticeAfter returns a command that emits clearNoticeMsg after d. +func clearNoticeAfter(d time.Duration) tea.Cmd { + return tea.Tick(d, func(time.Time) tea.Msg { return clearNoticeMsg{} }) +} + +// waitForFS blocks on the watcher channel and emits one fsEventMsg per change. +// It is re-issued after each event to form the listen loop; a closed channel +// ends the loop quietly. +func waitForFS(ch <-chan struct{}) tea.Cmd { + return func() tea.Msg { + if _, ok := <-ch; !ok { + return nil + } + return fsEventMsg{} + } +} + +// debounceCmd emits a debounceMsg for gen after the quiet period. +func debounceCmd(gen int) tea.Cmd { + return tea.Tick(debounceDelay, func(time.Time) tea.Msg { return debounceMsg{gen: gen} }) +} + +func (m *Model) focusedContent() string { + switch m.focused { + case paneTree: + if m.leftMode == modeView { + return m.fileView.Content() + } + return m.tree.Content() + case paneSpec: + return m.spec.Content() + case paneDiag: + return m.diag.Content() + } + return "" +} + +// updateFocused forwards a message to the currently focused panel (the left +// pane is the tree or the file viewer depending on leftMode). +func (m *Model) updateFocused(msg tea.Msg) tea.Cmd { + switch m.focused { + case paneTree: + if m.leftMode == modeView { + return m.fileView.Update(msg) + } + return m.tree.Update(msg) + case paneSpec: + return m.spec.Update(msg) + case paneDiag: + return m.diag.Update(msg) + } + return nil +} + +// recalcLayout distributes the terminal size: a header line, a top row with the +// source tree (1/3 width) beside the spec, a diagnostics strip, and a status +// line. The regions are stored for mouse hit-testing. +func (m *Model) recalcLayout() { + m.diagH = max(m.height/4, 5) + m.topH = max(m.height-headerH-statusH-m.diagH, 3) + m.leftW = max(min(m.width/3, m.width), 1) + rightW := max(m.width-m.leftW, 1) + + m.tree.SetSize(m.leftW, m.topH) + m.fileView.SetSize(m.leftW, m.topH) + m.spec.SetSize(rightW, m.topH) + m.diag.SetSize(m.width, m.diagH) +} + +// leftView renders whichever the left pane currently shows. The file viewer +// highlights its nav line when focused or when it is the active follower in +// spec-driven follow mode (where the spec keeps focus). +func (m *Model) leftView(focused bool) string { + if m.leftMode == modeView { + // The source pane is the active follower in spec- and diag-driven follow. + navActive := focused || m.follow == followSpec || m.follow == followDiag + return m.fileView.View(focused, navActive) + } + return m.tree.View(focused) +} + +// optionsView renders the scanner-options modal: a bordered list of boolean +// toggles with checkboxes and a cursor caret. +func (m *Model) optionsView() string { + lines, cursorLine := m.optionsLines() + lines = windowAround(lines, cursorLine, m.optionsVisibleRows()) + + var b strings.Builder + b.WriteString(theme.Accent().Render("Scanner options")) + fmt.Fprintf(&b, " %s\n\n", theme.Status().Render(fmt.Sprintf("(%d)", len(m.optToggles)))) + b.WriteString(strings.Join(lines, "\n")) + b.WriteString("\n\n") + b.WriteString(theme.Status().Render("↑↓/jk: move · space: toggle · esc/o: apply & close")) + + return theme.Modal().Render(b.String()) +} + +// optionsVisibleRows is how many rendered rows fit between the modal's chrome +// (border, padding, title, footer). The list outgrew a fixed layout at +// seventeen knobs plus headers, so it scrolls rather than overflowing a short +// terminal. +func (m *Model) optionsVisibleRows() int { + const chrome = 10 // border 2 + padding 2 + title 2 + footer 2, with slack + + return max(m.height-chrome, 3) +} + +// optionsLines renders the grouped rows and reports which rendered line the +// cursor sits on. Group headers are emitted as the group changes, so they are +// never navigable — the cursor indexes m.optToggles, not these lines. +func (m *Model) optionsLines() ([]string, int) { + labelW := 0 + for _, t := range m.optToggles { + labelW = max(labelW, len(t.label)) + } + + lines := make([]string, 0, len(m.optToggles)+2*optionGroupCount) + cursorLine, lastGroup := 0, "" + for i, t := range m.optToggles { + if t.group != lastGroup { + if lastGroup != "" { + lines = append(lines, "") + } + lines = append(lines, theme.Accent().Render(t.group)) + lastGroup = t.group + } + if i == m.optCursor { + cursorLine = len(lines) + } + lines = append(lines, m.optionRow(t, i == m.optCursor, labelW)) + } + + return lines, cursorLine +} + +// optionRow renders one toggle. A row whose dependency is unmet is dimmed and +// says why: ticking it would otherwise look like it had done something. +func (m *Model) optionRow(t optToggle, selected bool, labelW int) string { + box := "[ ]" + if *t.ptr { + box = "[x]" + } + caret := " " + if selected { + caret = "▸ " + } + + desc := t.desc + inert := !t.dep.satisfied() + if inert { + desc += t.dep.note() + } + + head := fmt.Sprintf("%s%s %-*s ", caret, box, labelW, t.label) + switch { + case selected: + // Highlight the whole row, description included. + return theme.Selected().Render(head + desc) + case inert: + return theme.Status().Render(head + desc) + default: + return head + theme.Status().Render(desc) + } +} + +// windowAround returns at most size lines, scrolled so that cursor is visible +// and as far from the edges as the list allows. +func windowAround(lines []string, cursor, size int) []string { + if len(lines) <= size { + return lines + } + top := clampInt(cursor-size/2, 0, len(lines)-size) + + return lines[top : top+size] +} + +// headerLine shows the app name, the (shortened) workdir, the active format, +// spec stats, and a scan spinner / ready indicator. +func (m *Model) headerLine() string { + // The banner claims its columns before the work dir does, so it survives a + // narrow terminal — discoverability is the whole point of it. + wd := shortenPath(m.cfg.WorkDir, max(m.width-54, 12)) + stats := fmt.Sprintf("%d paths · %d defs", m.numPaths, m.numDefs) + + if cur, total := m.spec.MatchInfo(); total > 0 { + stats += fmt.Sprintf(" · match %d/%d", cur, total) + } + + // Placed right after the app name rather than at the end of the line: a long + // work dir must never be able to push the one hint that reveals the others. + left := theme.Accent().Render("genspec-tui") + " " + theme.Chip().Render(" h: help ") + mid := theme.Status().Render(fmt.Sprintf(" · %s · %s · %s · ", wd, m.spec.Format(), stats)) + + tail := theme.Status().Render("ready") + switch { + case m.scanning: + tail = m.spin.View() + theme.Status().Render("scanning") + case m.lastElapsed > 0: + tail = theme.Status().Render("ready (" + humanDuration(m.lastElapsed) + ")") + } + return left + mid + tail +} + +// 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) + } +} + +func (m *Model) statusLine() string { + if m.searching { + return m.searchInput.View() + } + if m.follow != followOff { + return m.followBadge() + } + if m.notice != "" { + return theme.Status().Render(m.notice) + } + if m.refStatus != "" { + return theme.Accent().Render(" REFS ") + + theme.Status().Render(" "+m.refStatus+" · F3/shift+F3: next/prev · enter: go to definition · esc: clear") + } + if m.focused == paneTree && m.leftMode == modeView { + if m.fileView.Editing() { + return theme.Status().Render( + "editing · ctrl+f: jump → spec · esc: stop editing · ctrl+s: save · ctrl+q: quit") + } + return theme.Status().Render( + "viewing · ↑↓/jk: line · f: follow mode · i: edit · esc: tree · tab: focus · c: copy") + } + if m.focused == paneDiag && len(m.diags) > 0 { + return theme.Status().Render(fmt.Sprintf( + "diagnostic %d/%d · ↑↓/jk: select · f: follow mode · tab: focus · c: copy", + m.diagCursor+1, len(m.diags))) + } + if m.focused == paneSpec && m.specIndex.Len() > 0 { + if ptr, ok := m.specIndex.PointerAt(m.spec.CursorLine()); ok { + hint := "f: follow · F3: find refs · enter: go to definition · /: search · tab: focus · c: copy" + if _, isRef := m.refIndex.RefAt(m.spec.CursorLine()); !isRef { + // Nothing to follow from here; don't advertise it. + hint = "f: follow · F3: find refs · /: search · tab: focus · c: copy" + } + return theme.Status().Render("node " + ptr + " · " + hint) + } + } + return theme.Status().Render( + "tab/click: focus · enter: open file · g: locate · /: search · n/N: next/prev · o: options · c: copy · r: rescan · ctrl+q: quit") +} + +// followBadge renders the auto-follow status line: which pane drives, the +// resolved target, and how to exit. The accent label makes the mode obvious. +func (m *Model) followBadge() string { + label := "SPEC ▸ SOURCE" + switch m.follow { + case followSource: + label = "SOURCE ▸ SPEC" + case followDiag: + label = "DIAG ▸ SOURCE" + case followSpec, followOff: + } + target := m.followTarget + if target == "" { + target = "(move the cursor)" + } + badge := theme.Accent().Render(" " + label + " ") + if m.stale() { + badge += theme.Stale().Render(" STALE ") + } + return badge + theme.Status().Render(" "+target+" · esc / f: exit follow") +} + +// stale reports whether the cross-ref positions are out of date with respect to +// what the user is looking at. Provenance is a snapshot of the LAST scan, so an +// unsaved edit shifts every anchor below it in that file: the follower can land +// N lines off until Ctrl-S → watcher → rescan refreshes the index. Design §6.4 +// leaves the choice open between suppressing reverse-nav and badging it; a badge +// is the non-destructive read — nav keeps working, it just stops pretending to +// be exact. +func (m *Model) stale() bool { return m.fileView.Dirty() } + +// shortenPath trims a path from the left with an ellipsis so it fits maxLen. +func shortenPath(p string, maxLen int) string { + if maxLen < 4 { + maxLen = 4 + } + r := []rune(p) + if len(r) <= maxLen { + return p + } + return "…" + string(r[len(r)-maxLen+1:]) +} diff --git a/cmd/genspec-tui/internal/ux/model_diag_test.go b/cmd/genspec-tui/internal/ux/model_diag_test.go new file mode 100644 index 00000000..4a6222db --- /dev/null +++ b/cmd/genspec-tui/internal/ux/model_diag_test.go @@ -0,0 +1,124 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package ux + +import ( + "os" + "path/filepath" + "testing" + + "github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux/panels" + "github.com/go-openapi/codescan/internal/parsers/grammar" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +func TestDiag_MoveCursorClamps(t *testing.T) { + m := &Model{diag: panels.NewDiagnostics()} + m.diag.SetSize(60, 8) + m.diags = make([]grammar.Diagnostic, 3) + + m.moveDiagCursor(+1) + assert.Equal(t, 1, m.diagCursor) + m.moveDiagCursor(+5) + assert.Equal(t, 2, m.diagCursor, "clamped at the last diagnostic") + m.moveDiagCursor(-9) + assert.Equal(t, 0, m.diagCursor, "clamped at the first") + + // No diagnostics: a no-op, no panic. + empty := &Model{diag: panels.NewDiagnostics()} + empty.moveDiagCursor(+1) + assert.Equal(t, 0, empty.diagCursor) +} + +func TestDiag_FollowModeTracksSelection(t *testing.T) { + dir := t.TempDir() + a := filepath.Join(dir, "a.go") + b := filepath.Join(dir, "b.go") + require.NoError(t, os.WriteFile(a, []byte("package p\n\ntype X struct{}\n"), 0o600)) + require.NoError(t, os.WriteFile(b, []byte("package p\n\n\n\ntype Y struct{}\n"), 0o600)) + + m := &Model{fileView: panels.NewFileView(), diag: panels.NewDiagnostics()} + m.cfg.WorkDir = dir + m.fileView.SetSize(40, 12) + m.diag.SetSize(60, 8) + m.diags = []grammar.Diagnostic{ + grammar.Warnf(pos(a, 3, 1), grammar.CodeInvalidNumber, "one"), + grammar.Warnf(pos(b, 5, 1), grammar.CodeInvalidNumber, "two"), + } + m.focused = paneDiag + + // Entering follow mode mirrors the first diagnostic; the driver keeps focus. + m.toggleFollow(followDiag) + assert.Equal(t, followDiag, m.follow) + assert.Equal(t, paneDiag, m.focused, "the diagnostics pane stays the driver") + assert.Equal(t, a, m.currentFile) + assert.Equal(t, modeView, m.leftMode) + assert.Equal(t, 2, m.fileView.CurrentLine(), "line 3 → row 2") + + // Moving the selection auto-tracks the source pane (the Update loop re-syncs). + m.moveDiagCursor(+1) + m.syncFollowIfActive() + assert.Equal(t, b, m.currentFile, "source follows to the second diagnostic's file") + assert.Equal(t, 4, m.fileView.CurrentLine(), "line 5 → row 4") + + // A second `f` toggles off. + m.toggleFollow(followDiag) + assert.Equal(t, followOff, m.follow) +} + +func TestDiag_FollowExitsOnFocusChange(t *testing.T) { + m := &Model{fileView: panels.NewFileView(), diag: panels.NewDiagnostics()} + m.fileView.SetSize(40, 12) + m.diag.SetSize(60, 8) + m.diags = make([]grammar.Diagnostic, 2) + m.focused = paneDiag + m.follow = followDiag + + m.focused = paneSpec // tab/click away from the driver + m.syncFollowIfActive() + assert.Equal(t, followOff, m.follow, "leaving the driver pane exits follow") +} + +func TestDiag_FollowNoPosition(t *testing.T) { + m := &Model{fileView: panels.NewFileView(), diag: panels.NewDiagnostics()} + m.fileView.SetSize(40, 10) + m.diag.SetSize(60, 8) + m.diags = []grammar.Diagnostic{{Message: "no position"}} // zero Pos is invalid + m.focused = paneDiag + + m.toggleFollow(followDiag) + assert.Equal(t, followDiag, m.follow) + assert.Empty(t, m.currentFile, "nothing opened when the diagnostic has no source") + assert.Equal(t, "(diagnostic carries no position)", m.followTarget, + "a positionless diagnostic is a different miss from an unanchored spec node") +} + +func TestRenderDiagnostics_SelectedLine(t *testing.T) { + diags := []grammar.Diagnostic{ + grammar.Warnf(pos("/w/a.go", 1, 1), grammar.CodeInvalidNumber, "one"), + grammar.Warnf(pos("/w/a.go", 2, 1), grammar.CodeInvalidNumber, "two"), + } + // tally on line 0, first diagnostic on line 1, second on line 2. + _, line := renderDiagnostics("/w", nil, diags, 1, true) + assert.Equal(t, 2, line) +} + +// Spotted in a screenshot: the spec cursor and the selected diagnostic were +// both drawn with the strong bar, so two panes appeared to be driving at once. +// The rule everywhere else is focused = strong, unfocused = muted tint. +func TestDiag_SelectionDimsWhenUnfocused(t *testing.T) { + diags := []grammar.Diagnostic{ + grammar.Warnf(pos("a.go", 3, 1), grammar.CodeInvalidNumber, "one"), + grammar.Warnf(pos("b.go", 5, 1), grammar.CodeInvalidNumber, "two"), + } + + focused, _ := renderDiagnostics("", nil, diags, 1, true) + unfocused, _ := renderDiagnostics("", nil, diags, 1, false) + + assert.NotEqual(t, focused, unfocused, + "the selected row must look different depending on whether the pane drives") + assert.Equal(t, stripANSI(focused), stripANSI(unfocused), + "...but only in styling — the text is the same either way") +} diff --git a/cmd/genspec-tui/internal/ux/model_diagmarks_test.go b/cmd/genspec-tui/internal/ux/model_diagmarks_test.go new file mode 100644 index 00000000..474917f5 --- /dev/null +++ b/cmd/genspec-tui/internal/ux/model_diagmarks_test.go @@ -0,0 +1,266 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package ux + +import ( + "go/token" + "os" + "path/filepath" + "slices" + "strings" + "sync" + "testing" + + "github.com/go-openapi/codescan" + "github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux/panels" + "github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux/theme" + "github.com/go-openapi/codescan/internal/parsers/grammar" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// go/token counts BYTES and textarea substitutes four spaces per tab, so a file +// column and a displayed column are two conversions apart. Getting this wrong is +// how `int64` once drew as `int` plus a green `64`. +func TestBufferColumn(t *testing.T) { + for _, tc := range []struct { + name string + line string + byteCol int + want int + }{ + {"first column is untouched", "package p", 1, 1}, + {"no tabs, no multi-byte: identity", "package p", 9, 9}, + {"one leading tab is four columns", "\t// in: formData", 5, 8}, + {"two leading tabs are eight", "\t\t// in: formData", 6, 12}, + {"a tab mid-line counts too", "a\tb", 3, 6}, + {"multi-byte runes are one column each", "// héllo x", 11, 10}, + {"past the end clamps to the line", "ab", 99, 3}, + } { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, bufferColumn(tc.line, tc.byteCol)) + }) + } +} + +func TestDiagKind_CoversEverySeverity(t *testing.T) { + assert.Equal(t, theme.SyntaxDiagError, diagKind(grammar.SeverityError)) + assert.Equal(t, theme.SyntaxDiagWarn, diagKind(grammar.SeverityWarning)) + assert.Equal(t, theme.SyntaxDiagHint, diagKind(grammar.SeverityHint)) +} + +// classificationOnce caches the malformed-input corpus scan for the package, for +// the same reason petstoreOnce caches the petstore one: a real packages.Load +// costs seconds under -race, and three of these tests need the same scan. +var ( + classificationOnce sync.Once //nolint:gochecknoglobals // test-only scan cache + classificationRes scanResultMsg //nolint:gochecknoglobals // test-only scan cache +) + +// classificationScan is the cached scan of the fixture corpus that deliberately +// contains malformed input. Diagnostics are CLONED per caller: one of these +// tests rewrites their filenames, and the cache is shared. +func classificationScan(t *testing.T) scanResultMsg { + t.Helper() + + classificationOnce.Do(func() { + classificationRes = doScan(codescan.Options{ + WorkDir: fixturesDir(t), + Packages: []string{"./goparsing/classification/..."}, + }) + }) + require.NoError(t, classificationRes.err) + require.NotEmpty(t, classificationRes.diags, "the corpus must produce diagnostics") + + res := classificationRes + res.diags = slices.Clone(classificationRes.diags) + + return res +} + +// classificationModel opens one of the corpus's files against that scan. +func classificationModel(t *testing.T, name string) (*Model, string) { + t.Helper() + + dir := fixturesDir(t) + res := classificationScan(t) + + m := &Model{spec: panels.NewSpec(), fileView: panels.NewFileView()} + m.cfg.WorkDir = dir + m.spec.SetSize(100, 30) + m.fileView.SetSize(90, 30) + m.diags = res.diags + m.specJSON = res.json + + path := filepath.Join(dir, "goparsing", "classification", name) + m.loadFileQuietly(path) + + return m, path +} + +// End to end against a real scan: the diagnostic the pane below lists must be +// drawn on the token it names, in the coordinates the pane draws in. +func TestE2E_DiagnosticMarksTheOffendingKeyword(t *testing.T) { + m, path := classificationModel(t, filepath.Join("operations", "noparams.go")) + + marks := m.sourceMarks() + require.NotEmpty(t, marks, "this fixture is the malformed-input corpus") + + buffer := strings.Split(m.fileView.Value(), "\n") + for _, mark := range marks { + require.Less(t, mark.Line, len(buffer)) + runes := []rune(buffer[mark.Line]) + require.LessOrEqual(t, mark.Col, len(runes)+1, + "line %d: a mark past the end of the line it is on", mark.Line+1) + } + + // `// in: formData` is reported at the keyword; after translation it must + // still be the keyword, not the tab that precedes it. + source := strings.Split(m.currentSource, "\n") + var checked int + for _, d := range m.diags { + if d.Pos.Filename != path || !strings.Contains(source[d.Pos.Line-1], "// in:") { + continue + } + col := bufferColumn(source[d.Pos.Line-1], d.Pos.Column) + assert.True(t, strings.HasPrefix(string([]rune(buffer[d.Pos.Line-1])[col-1:]), "in:"), + "line %d landed on %q", d.Pos.Line, buffer[d.Pos.Line-1]) + checked++ + } + require.Positive(t, checked, "the fixture must still contain a context-invalid `in:`") +} + +// The mark has to survive all the way to the screen, over the lexical class the +// token would otherwise have had. +func TestE2E_DiagnosticStyleReachesTheRenderedPane(t *testing.T) { + m, _ := classificationModel(t, filepath.Join("operations", "noparams.go")) + + marks := m.sourceMarks() + require.NotEmpty(t, marks) + m.fileView.GotoLine(marks[0].Line) + + view := m.fileView.View(false, false) + + assert.Contains(t, view, runOpenerFor(t, marks[0].Kind), + "the diagnostic's style is drawn in the source pane") +} + +// Diagnostics name their file. Marking a line number from another file would +// underline whatever happens to be there. +func TestDiagMarks_OtherFilesDoNotMarkThisOne(t *testing.T) { + m, path := classificationModel(t, filepath.Join("operations", "noparams.go")) + require.NotEmpty(t, m.sourceMarks()) + + for i := range m.diags { + m.diags[i].Pos.Filename = path + ".elsewhere" + } + + assert.Empty(t, m.sourceMarks(), "another file's diagnostics stay in it") +} + +// A rescan replaces the diagnostics; before this was wired the open file went on +// showing the previous scan's marks while the pane below listed the new ones. +func TestDiagMarks_RescanRefreshesTheOpenFile(t *testing.T) { + path := writeTempGo(t, annotatedGo) + m := goViewerModel(t, path) + m.currentFile = path + require.Empty(t, m.sourceMarks(), "a clean file starts unmarked") + + // Line 3 is `// A user of the system.`, prose — so an unmarked comment run. + before := m.fileView.View(false, false) + require.NotContains(t, before, runOpenerFor(t, theme.SyntaxDiagError)) + + m.diags = []grammar.Diagnostic{{ + Pos: token.Position{Filename: path, Line: 4, Column: 1}, + Severity: grammar.SeverityError, + Code: grammar.CodeUnexpectedToken, + Message: "invented for this test", + }} + m.refreshSource() + + assert.Contains(t, m.fileView.View(false, false), runOpenerFor(t, theme.SyntaxDiagError), + "the new scan's marks are on screen") +} + +// ...and through the loop that actually delivers a rescan. Calling refreshSource +// directly proves the function works; only this proves it is WIRED, which is the +// half that was missing. +func TestDiagMarks_RescanThroughTheUpdateLoop(t *testing.T) { + path := writeTempGo(t, annotatedGo) + m := goViewerModel(t, path) + m.currentFile = path + m.spec.SetSize(80, 20) + require.NotContains(t, m.fileView.View(false, false), runOpenerFor(t, theme.SyntaxDiagError)) + + _, _ = m.Update(scanResultMsg{ + json: "{}", + diags: []grammar.Diagnostic{{ + Pos: token.Position{Filename: path, Line: 4, Column: 1}, + Severity: grammar.SeverityError, + Code: grammar.CodeUnexpectedToken, + Message: "invented for this test", + }}, + }) + + assert.Contains(t, m.fileView.View(false, false), runOpenerFor(t, theme.SyntaxDiagError), + "a rescan must re-derive the open file, not only the panes below it") +} + +// Opening a different file must not carry the previous one's marks across. +func TestDiagMarks_ClearedWhenTheFileChanges(t *testing.T) { + m, _ := classificationModel(t, filepath.Join("operations", "noparams.go")) + require.NotEmpty(t, m.sourceMarks()) + + clean := writeTempGo(t, annotatedGo) + m.loadFileQuietly(clean) + + assert.Empty(t, m.sourceMarks()) + assert.NotContains(t, m.fileView.View(false, false), runOpenerFor(t, theme.SyntaxDiagError)) +} + +// A read failure leaves an error message in the buffer; marks resolved against +// the file that is no longer loaded would colour that message by its columns. +func TestDiagMarks_ReadErrorDropsThem(t *testing.T) { + m, _ := classificationModel(t, filepath.Join("operations", "noparams.go")) + require.NotEmpty(t, m.sourceMarks()) + + m.loadFileQuietly(filepath.Join(t.TempDir(), "gone.go")) + + assert.Empty(t, m.sourceMarks()) + assert.Contains(t, stripANSI(m.fileView.View(false, false)), "error reading file") +} + +// Over every Go file the malformed-input corpus scans: no mark may land past the +// end of the line it is on, at any indentation. +func TestE2E_NoMarkLandsPastItsLine(t *testing.T) { + dir := fixturesDir(t) + res := classificationScan(t) + + files := map[string]bool{} + for _, d := range res.diags { + files[d.Pos.Filename] = true + } + + var checked int + for path := range files { + if _, err := os.Stat(path); err != nil { + continue + } + m := &Model{spec: panels.NewSpec(), fileView: panels.NewFileView()} + m.cfg.WorkDir = dir + m.fileView.SetSize(120, 30) + m.diags = res.diags + m.loadFileQuietly(path) + + buffer := strings.Split(m.fileView.Value(), "\n") + for _, mark := range m.sourceMarks() { + require.Less(t, mark.Line, len(buffer), path) + require.LessOrEqual(t, mark.Col, len([]rune(buffer[mark.Line]))+1, + "%s:%d: mark at column %d, line is %q", + filepath.Base(path), mark.Line+1, mark.Col, buffer[mark.Line]) + checked++ + } + } + require.Positive(t, checked, "the corpus must produce marks to check") +} diff --git a/cmd/genspec-tui/internal/ux/model_diagnav_test.go b/cmd/genspec-tui/internal/ux/model_diagnav_test.go new file mode 100644 index 00000000..7d4893fb --- /dev/null +++ b/cmd/genspec-tui/internal/ux/model_diagnav_test.go @@ -0,0 +1,163 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package ux + +import ( + "go/token" + "os" + "path/filepath" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux/panels" + "github.com/go-openapi/codescan/internal/parsers/grammar" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// The diagnostics pane was the last one where the view and the selection could +// drift apart: the wheel scrolled the viewport without moving the cursor, and +// every cursor step re-centred the whole list. These pin the corrected +// behaviour, matching the spec pane and the source viewer. + +const diagNavCount = 30 + +// diagNavModel builds a model with many diagnostics over two files, so paging +// and file-switching both have somewhere to go. +func diagNavModel(t *testing.T) (*Model, string) { + t.Helper() + + dir := t.TempDir() + src := filepath.Join(dir, "many.go") + require.NoError(t, os.WriteFile(src, []byte(strings.Repeat("package p\n", diagNavCount+2)), 0o600)) + + m := &Model{fileView: panels.NewFileView(), diag: panels.NewDiagnostics(), spec: panels.NewSpec()} + m.cfg.WorkDir = dir + m.diagH = 10 + m.diag.SetSize(80, 10) + m.fileView.SetSize(80, 10) + m.focused = paneDiag + + m.diags = make([]grammar.Diagnostic, 0, diagNavCount) + for i := range diagNavCount { + pos := token.Position{Filename: src, Line: i + 1, Column: 1} + m.diags = append(m.diags, grammar.Warnf(pos, grammar.CodeInvalidNumber, "diag %d", i)) + } + m.refreshDiagnostics() + + return m, src +} + +func TestDiagNav_Paging(t *testing.T) { + m, _ := diagNavModel(t) + page := m.diag.VisibleRows() + require.Positive(t, page) + + _, _ = m.handleKey(tea.KeyMsg{Type: tea.KeyPgDown}) + assert.Equal(t, page, m.diagCursor, "page down moves a viewport's worth") + + _, _ = m.handleKey(tea.KeyMsg{Type: tea.KeyPgUp}) + assert.Zero(t, m.diagCursor) + + _, _ = m.handleKey(tea.KeyMsg{Type: tea.KeyEnd}) + assert.Equal(t, diagNavCount-1, m.diagCursor, "end selects the last diagnostic") + + _, _ = m.handleKey(tea.KeyMsg{Type: tea.KeyHome}) + assert.Zero(t, m.diagCursor) +} + +// The wheel must move the selection, not just the view — otherwise you can +// scroll away and then `f` or Enter acts on a diagnostic you cannot see. +func TestDiagNav_WheelMovesTheSelection(t *testing.T) { + m, _ := diagNavModel(t) + + for range 3 { + _, _ = m.handleMouse(tea.MouseMsg{ + Button: tea.MouseButtonWheelDown, + Action: tea.MouseActionPress, + X: 1, + Y: headerH + m.topH, // inside the diagnostics strip + }) + } + + assert.Equal(t, 3, m.diagCursor, "the wheel carried the selection with it") +} + +// Stepping the selection must not shift the whole list under the reader. +func TestDiagNav_ScrollsMinimally(t *testing.T) { + m, _ := diagNavModel(t) + page := m.diag.VisibleRows() + + // Moving inside the visible window leaves the viewport alone. + before := m.diag.TopLine() + _, _ = m.handleKey(tea.KeyMsg{Type: tea.KeyDown}) + assert.Equal(t, before, m.diag.TopLine(), "the target was already on screen") + + // Walking past the bottom edge scrolls by exactly one line at a time. + for range page + 2 { + _, _ = m.handleKey(tea.KeyMsg{Type: tea.KeyDown}) + } + assert.Positive(t, m.diag.TopLine(), "it did scroll once the selection left the window") + assert.Less(t, m.diag.TopLine(), m.diagCursor+1, "and no further than needed") +} + +// Enter is the one-shot counterpart to `f`: it goes to the source AND moves +// focus, where follow mode keeps the diagnostics pane driving. +func TestDiagNav_EnterJumpsToSource(t *testing.T) { + m, src := diagNavModel(t) + _, _ = m.handleKey(tea.KeyMsg{Type: tea.KeyDown}) + _, _ = m.handleKey(tea.KeyMsg{Type: tea.KeyDown}) + require.Equal(t, 2, m.diagCursor) + + _, _ = m.handleKey(tea.KeyMsg{Type: tea.KeyEnter}) + + assert.Equal(t, src, m.currentFile, "the producing file is open") + assert.Equal(t, modeView, m.leftMode) + assert.Equal(t, paneTree, m.focused, "focus moved to the source, unlike follow mode") + assert.Equal(t, 2, m.fileView.CurrentLine(), "on the diagnostic's line (3, 0-based 2)") + assert.Contains(t, m.notice, "many.go:3") +} + +func TestDiagNav_EnterWithoutAPosition(t *testing.T) { + m := &Model{fileView: panels.NewFileView(), diag: panels.NewDiagnostics()} + m.diagH = 10 + m.diag.SetSize(80, 10) + m.focused = paneDiag + m.diags = []grammar.Diagnostic{ + grammar.Warnf(token.Position{}, grammar.CodeInvalidNumber, "no position"), + } + m.refreshDiagnostics() + + _, _ = m.handleKey(tea.KeyMsg{Type: tea.KeyEnter}) + + assert.Empty(t, m.currentFile, "nothing was opened") + assert.Contains(t, m.notice, "no position") +} + +func TestDiagNav_EnterWithNoDiagnostics(t *testing.T) { + m := &Model{fileView: panels.NewFileView(), diag: panels.NewDiagnostics()} + m.diag.SetSize(80, 10) + m.focused = paneDiag + + _, _ = m.handleKey(tea.KeyMsg{Type: tea.KeyEnter}) + + assert.Empty(t, m.currentFile) + assert.Empty(t, m.notice) +} + +// The pane still declines keys it does not own, so the globals keep working. +func TestDiagNav_UnownedKeysFallThrough(t *testing.T) { + m, _ := diagNavModel(t) + m.specJSON = `{"swagger":"2.0"}` + m.refreshSpec() + + _, _ = m.handleKey(keyRune('h')) + assert.True(t, m.helpOpen, "h still reaches the global help") + + _, _ = m.handleKey(tea.KeyMsg{Type: tea.KeyEsc}) + _, _ = m.handleKey(keyRune('r')) + assert.True(t, m.scanning, "r still rescans") +} diff --git a/cmd/genspec-tui/internal/ux/model_e2e_test.go b/cmd/genspec-tui/internal/ux/model_e2e_test.go new file mode 100644 index 00000000..6572d87d --- /dev/null +++ b/cmd/genspec-tui/internal/ux/model_e2e_test.go @@ -0,0 +1,239 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package ux + +import ( + "path/filepath" + "runtime" + "strings" + "sync" + "testing" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + + "github.com/go-openapi/codescan" + "github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux/panels" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// D3 — the whole chain against a REAL scan. +// +// Every other test in this package feeds the model hand-written JSON. That +// proves the indexes and the navigation agree with each other, but not that +// either agrees with what codescan actually emits: whether $refs arrive bare or +// allOf-wrapped, whether definition names survive as written, whether the +// provenance pointers line up with the rendered document. This scans the +// petstore fixture and drives the finished model over the result. + +// fixturesDir resolves the repo-level fixtures/ directory from this file's own +// location, so the test runs from any working directory (CI runs it from +// cmd/genspec-tui, not the repo root). Deliberately local rather than borrowing +// scantest.FixturesDir — the TUI module should not grow a dependency on the +// library's test helpers for one path join. +func fixturesDir(t *testing.T) string { + t.Helper() + _, thisFile, _, ok := runtime.Caller(0) + require.True(t, ok, "cannot resolve the caller's file path") + + // thisFile is /cmd/genspec-tui/internal/ux/model_e2e_test.go + return filepath.Clean(filepath.Join(filepath.Dir(thisFile), "..", "..", "..", "..", "fixtures")) +} + +// petstoreScan caches the scan across the whole package. A real scan means a +// real packages.Load, which costs seconds under -race; running one per test +// took this package from ~1s to ~36s. The result is immutable, and each test +// still gets its own Model built from it. Mirrors the caching the library's own +// scantest helpers do for the same reason. +var ( + petstoreOnce sync.Once //nolint:gochecknoglobals // test-only scan cache + petstoreRes scanResultMsg //nolint:gochecknoglobals // test-only scan cache +) + +// scanPetstore hands the cached scan to a fresh Model through the same message +// the bubbletea loop delivers. +func scanPetstore(t *testing.T) *Model { + t.Helper() + + petstoreOnce.Do(func() { + petstoreRes = doScan(codescan.Options{ + WorkDir: fixturesDir(t), + Packages: []string{"./goparsing/petstore/..."}, + ScanModels: true, + }) + }) + res := petstoreRes + require.NoError(t, res.err, "the petstore fixture must scan cleanly") + require.NotEmpty(t, res.json) + + m := &Model{ + spec: panels.NewSpec(), + fileView: panels.NewFileView(), + searchInput: textinput.New(), + } + m.cfg.WorkDir = fixturesDir(t) // so relTo renders paths relative to the scan root + m.spec.SetSize(100, 30) + m.fileView.SetSize(100, 30) + m.focused = paneSpec + _, _ = m.Update(res) + + return m +} + +// specLines is the rendered document the indexes were built from. +func specLines(m *Model) []string { return strings.Split(m.specJSON, "\n") } + +func TestE2E_RefIndexMatchesTheRenderedSpec(t *testing.T) { + m := scanPetstore(t) + lines := specLines(m) + + // The petstore references /definitions/pet from several operations. The + // exact count is fixture-dependent, so assert the property that matters. + sites := m.refIndex.RefsToPointer("/definitions/pet") + require.GreaterOrEqual(t, len(sites), 2, "pet must be referenced from at least two places") + + var last int + for i, site := range sites { + require.Less(t, site.Line, len(lines), "site line is inside the document") + + // Every recorded site must really be a $ref pointing where we claim. + text := lines[site.Line] + assert.Contains(t, text, `"$ref"`, "line %d", site.Line) + assert.Contains(t, text, "#/definitions/pet", "line %d", site.Line) + + if i > 0 { + assert.Greater(t, site.Line, last, "sites are ordered by rendered line") + } + last = site.Line + + // And the node holding it must be addressable in the spec index. + _, ok := m.specIndex.LineForPointer(site.Pointer) + assert.True(t, ok, "holder %q is a real node", site.Pointer) + } +} + +// codescan emits $refs to responses as well as definitions; both must index. +func TestE2E_ResponseRefsIndex(t *testing.T) { + m := scanPetstore(t) + + sites := m.refIndex.RefsToPointer("/responses/genericError") + require.GreaterOrEqual(t, len(sites), 2, "the shared error response is reused across operations") + + for _, site := range sites { + assert.True(t, site.Target.Local) + assert.Equal(t, "/responses/genericError", site.Target.Pointer) + } +} + +// The round trip: park on a definition, F3 to one of its uses, Enter to come +// back. If either index disagreed with the render, this would land elsewhere. +func TestE2E_CycleThenGoToDefinitionRoundTrips(t *testing.T) { + m := scanPetstore(t) + + defLine, ok := m.specIndex.LineForPointer("/definitions/pet") + require.True(t, ok) + m.spec.SetCursor(defLine) + + // F3 → the first use. + _, _ = m.handleKey(tea.KeyMsg{Type: tea.KeyF3}) + require.Contains(t, m.refStatus, "of /definitions/pet") + firstUse := m.spec.TopLine() + + // F3 again → a different use. + _, _ = m.handleKey(tea.KeyMsg{Type: tea.KeyF3}) + assert.NotEqual(t, firstUse, m.spec.TopLine(), "the cycle advanced to another site") + require.Contains(t, m.refStatus, "ref 2/") + + // Enter on that $ref → back to the definition. + _, _ = m.handleKey(tea.KeyMsg{Type: tea.KeyEnter}) + assert.Equal(t, "→ /definitions/pet", m.notice) + assert.Contains(t, specLines(m)[defLine], `"pet"`, + "the line we came back to really is the pet definition") +} + +func TestE2E_CycleVisitsEverySiteExactlyOnce(t *testing.T) { + m := scanPetstore(t) + + defLine, ok := m.specIndex.LineForPointer("/definitions/pet") + require.True(t, ok) + m.spec.SetCursor(defLine) + + want := m.refIndex.RefsToPointer("/definitions/pet") + require.NotEmpty(t, want) + + seen := make(map[int]int, len(want)) + for range want { + _, _ = m.handleKey(tea.KeyMsg{Type: tea.KeyF3}) + seen[m.refSites[m.refCursor].Line]++ + } + + assert.Len(t, seen, len(want), "one full lap visits every site") + for line, n := range seen { + assert.Equal(t, 1, n, "site at line %d visited once", line) + } + + // One more step wraps back to the start. + _, _ = m.handleKey(tea.KeyMsg{Type: tea.KeyF3}) + assert.Contains(t, m.refStatus, "ref 1/") +} + +// Both renders of the same scan must find the same reference sites — only the +// line numbers differ. This is what makes ctrl+j/ctrl+y safe mid-investigation. +func TestE2E_YAMLFindsTheSameSites(t *testing.T) { + m := scanPetstore(t) + require.NotEmpty(t, m.specYAML, "the scan produced a YAML render") + + jsonHolders := holderSet(m, "/definitions/pet") + require.NotEmpty(t, jsonHolders) + + m.setSpecFormat("YAML") + require.Equal(t, "YAML", m.spec.Format()) + + assert.Equal(t, jsonHolders, holderSet(m, "/definitions/pet"), + "the same nodes reference pet in either render") +} + +func holderSet(m *Model, target string) map[string]bool { + out := make(map[string]bool) + for _, site := range m.refIndex.RefsToPointer(target) { + out[site.Pointer] = true + } + + return out +} + +// The two halves of the linker must agree on a real scan: a definition the ref +// index points at should also be a node the provenance index can take to source. +func TestE2E_RefTargetsHaveSource(t *testing.T) { + m := scanPetstore(t) + require.Positive(t, m.srcIndex.Len(), "the scan emitted provenance") + + for _, target := range []string{"/definitions/pet", "/definitions/order"} { + require.NotEmpty(t, m.refIndex.RefsToPointer(target), "%s is referenced", target) + + pos, ok := m.srcIndex.PositionFor(target) + require.True(t, ok, "%s resolves to source", target) + assert.True(t, strings.HasSuffix(pos.Filename, ".go"), "%s → %s", target, pos.Filename) + assert.Positive(t, pos.Line) + } +} + +// Spec→source follow, end to end: park on a definition, turn on follow, and the +// source pane must open the Go file that actually declares it. +func TestE2E_FollowOpensTheDeclaringFile(t *testing.T) { + m := scanPetstore(t) + + defLine, ok := m.specIndex.LineForPointer("/definitions/pet") + require.True(t, ok) + m.spec.SetCursor(defLine) + + m.toggleFollow(followSpec) + + require.Equal(t, followSpec, m.follow) + assert.True(t, strings.HasSuffix(m.currentFile, ".go"), "opened %q", m.currentFile) + assert.Contains(t, m.followTarget, "/definitions/pet") + assert.Contains(t, m.fileView.Content(), "type Pet struct", + "the follower landed in the file that declares the type") +} diff --git a/cmd/genspec-tui/internal/ux/model_edges_test.go b/cmd/genspec-tui/internal/ux/model_edges_test.go new file mode 100644 index 00000000..2f966c63 --- /dev/null +++ b/cmd/genspec-tui/internal/ux/model_edges_test.go @@ -0,0 +1,284 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package ux + +import ( + "go/token" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux/index" + "github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux/panels" + "github.com/go-openapi/codescan/internal/scanner" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// The two renders of the same spec, indexing the same pointers at DIFFERENT +// lines — the whole point of preserving the pointer rather than the line +// across a format toggle. Both renders are deliberately taller than the test +// viewport (below) so neither clamps: YAML is roughly half the height of JSON, +// which is exactly why carrying the raw line number across is wrong. +const ( + toggleJSON = `{ + "definitions": { + "Address": { + "properties": { + "city": { + "type": "string" + }, + "zip": { + "type": "string" + } + } + }, + "User": { + "properties": { + "email": { + "type": "string" + }, + "name": { + "type": "string" + } + } + } + } +}` + + toggleYAML = `definitions: + Address: + properties: + city: + type: string + zip: + type: string + User: + properties: + email: + type: string + name: + type: string +` +) + +// The email property's line in each render — the node the toggle must preserve. +const ( + emailPtr = "/definitions/User/properties/email" + emailJSONLine = 14 + emailYAMLLine = 9 +) + +// toggleFixture builds a model holding both renders of the same spec, with the +// spec pane focused and the JSON index live. The pane is short on purpose (a +// 5-line viewport) so both renders can actually scroll to the target node. +func toggleFixture(t *testing.T) *Model { + t.Helper() + m := &Model{spec: panels.NewSpec(), fileView: panels.NewFileView()} + m.spec.SetSize(60, 8) + m.fileView.SetSize(60, 8) + m.specJSON, m.specYAML = toggleJSON, toggleYAML + m.focused = paneSpec + m.refreshSpec() + return m +} + +func TestSpecFormatToggle_PreservesPointerNotLine(t *testing.T) { + m := toggleFixture(t) + + // Park the viewport on the `email` property. + jsonLine, ok := m.specIndex.LineForPointer(emailPtr) + require.True(t, ok, "the email property must be indexed in the JSON render") + require.Equal(t, emailJSONLine, jsonLine) + m.spec.SetCursor(jsonLine) + require.Equal(t, jsonLine, m.spec.CursorLine()) + + m.setSpecFormat("YAML") + + require.Equal(t, "YAML", m.spec.Format()) + yamlLine, ok := m.specIndex.LineForPointer(emailPtr) + require.True(t, ok, "the same pointer must be indexed in the YAML render") + require.Equal(t, emailYAMLLine, yamlLine, "the two renders put the node on different lines") + + assert.Equal(t, yamlLine, m.spec.CursorLine(), + "the toggle must land on the same NODE, not the same line number") + + // Round-tripping back restores the JSON line for the same node. + m.setSpecFormat("JSON") + assert.Equal(t, jsonLine, m.spec.CursorLine()) +} + +func TestSpecFormatToggle_SameFormatIsNoop(t *testing.T) { + m := toggleFixture(t) + m.spec.SetCursor(emailJSONLine) + + m.setSpecFormat("JSON") + + assert.Equal(t, emailJSONLine, m.spec.CursorLine(), + "re-selecting the active format must not move the cursor") +} + +func TestSpecFormatToggle_UnindexedCursorLine(t *testing.T) { + m := toggleFixture(t) + m.specIndex = nil // no index: nothing to preserve, but nothing may panic either + + m.setSpecFormat("YAML") + + assert.Equal(t, "YAML", m.spec.Format()) +} + +// TestSpecFormatToggle_ViaKey checks the binding actually routes through the +// pointer-preserving path (the bug was in the key handler, not the helper). +func TestSpecFormatToggle_ViaKey(t *testing.T) { + m := toggleFixture(t) + m.spec.SetCursor(emailJSONLine) + + _, _ = m.handleKey(tea.KeyMsg{Type: tea.KeyCtrlY}) + + assert.Equal(t, "YAML", m.spec.Format()) + assert.Equal(t, emailYAMLLine, m.spec.CursorLine(), + "ctrl+y must preserve the node under the cursor") +} + +func TestLinkSourceToSpec_NamesEachMiss(t *testing.T) { + const ptr = "/definitions/User" + + t.Run("no file open", func(t *testing.T) { + m := &Model{spec: panels.NewSpec(), fileView: panels.NewFileView()} + desc, moved := m.linkSourceToSpec() + assert.False(t, moved) + assert.Equal(t, noFileDesc, desc) + }) + + t.Run("no provenance at all", func(t *testing.T) { + m := &Model{spec: panels.NewSpec(), fileView: panels.NewFileView()} + m.currentFile = "user.go" + m.fileView.SetFile("user.go", "a\nb\nc") + m.srcIndex = index.BuildSourceIndex(nil) + + desc, moved := m.linkSourceToSpec() + assert.False(t, moved) + assert.Equal(t, noProvenanceDesc, desc, + "an empty index means nothing was anchored — not that this line is special") + }) + + t.Run("anchored file but line above the first anchor", func(t *testing.T) { + m := &Model{spec: panels.NewSpec(), fileView: panels.NewFileView()} + m.currentFile = "user.go" + m.fileView.SetFile("user.go", "a\nb\nc") + m.srcIndex = index.BuildSourceIndex([]scanner.Provenance{ + {Pointer: ptr, Pos: token.Position{Filename: "user.go", Line: 3}}, + }) + m.fileView.GotoLine(0) // source line 1 + + desc, moved := m.linkSourceToSpec() + assert.False(t, moved) + assert.Equal(t, noAnchorDesc, desc) + }) + + t.Run("anchored but not rendered in this view", func(t *testing.T) { + m := &Model{spec: panels.NewSpec(), fileView: panels.NewFileView()} + m.spec.SetSize(60, 10) + m.currentFile = "user.go" + m.fileView.SetFile("user.go", "a\nb\nc") + m.srcIndex = index.BuildSourceIndex([]scanner.Provenance{ + {Pointer: ptr, Pos: token.Position{Filename: "user.go", Line: 1}}, + }) + // The spec index knows a different node entirely, so the pointer + // resolves on the source side but has nowhere to land. + m.specIndex = index.NewSpecIndex( + map[int]string{0: "/definitions/Other"}, + map[string]int{"/definitions/Other": 0}, + ) + m.fileView.GotoLine(0) + + desc, moved := m.linkSourceToSpec() + assert.False(t, moved) + assert.Equal(t, ptr+notRenderedSuffix, desc, + "a node that exists but isn't in this render is a different answer from no source") + }) +} + +func TestDriveSpecToSource_NamesEachMiss(t *testing.T) { + newModel := func() *Model { + m := &Model{spec: panels.NewSpec(), fileView: panels.NewFileView()} + m.spec.SetSize(60, 10) + m.spec.SetContent("line0\nline1\nline2") + m.specIndex = index.NewSpecIndex( + map[int]string{0: "/definitions/User"}, + map[string]int{"/definitions/User": 0}, + ) + return m + } + + t.Run("no provenance at all", func(t *testing.T) { + m := newModel() + m.srcIndex = index.BuildSourceIndex(nil) + + desc := m.driveSpecToSource() + assert.Contains(t, desc, noProvenanceDesc) + assert.Empty(t, m.currentFile, "the follower holds rather than jumping") + }) + + t.Run("spec-only node", func(t *testing.T) { + m := newModel() + // Some other node is anchored, so the index is populated — this node + // simply wasn't produced from code (the InputSpec overlay case, §3.8). + m.srcIndex = index.BuildSourceIndex([]scanner.Provenance{ + {Pointer: "/definitions/Other", Pos: token.Position{Filename: "other.go", Line: 1}}, + }) + + desc := m.driveSpecToSource() + assert.Equal(t, "/definitions/User"+noSourceSuffix, desc) + assert.Empty(t, m.currentFile, "the follower holds rather than jumping") + }) + + t.Run("no node under the viewport top", func(t *testing.T) { + m := newModel() + m.specIndex = nil + m.srcIndex = index.BuildSourceIndex(nil) + + assert.Equal(t, noNodeDesc, m.driveSpecToSource()) + }) +} + +func TestFollowBadge_StaleWhileDirty(t *testing.T) { + m := &Model{spec: panels.NewSpec(), fileView: panels.NewFileView()} + m.fileView.SetFile("user.go", "package p\n") + m.follow = followSource + m.followTarget = "/definitions/User" + + require.False(t, m.stale(), "a freshly loaded buffer matches the last scan") + assert.NotContains(t, stripANSI(m.followBadge()), "STALE") + + // An unsaved edit shifts every anchor below it: positions are now older + // than what is on screen. + m.fileView.StartEdit() + _ = m.fileView.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}}) + + require.True(t, m.stale(), "an edited buffer invalidates the recorded positions") + assert.Contains(t, stripANSI(m.followBadge()), "STALE") + + // Saving (MarkClean is what saveFile does) clears it again. + m.fileView.MarkClean() + assert.False(t, m.stale()) + assert.NotContains(t, stripANSI(m.followBadge()), "STALE") +} + +// stripANSI removes the SGR escape sequences lipgloss emits, so assertions can +// look at the text a user reads rather than the styling around it. +func stripANSI(s string) string { + var b strings.Builder + for i := 0; i < len(s); i++ { + if s[i] == 0x1b { + for i < len(s) && s[i] != 'm' { + i++ + } + continue + } + b.WriteByte(s[i]) + } + return b.String() +} diff --git a/cmd/genspec-tui/internal/ux/model_follow_test.go b/cmd/genspec-tui/internal/ux/model_follow_test.go new file mode 100644 index 00000000..249a6ea4 --- /dev/null +++ b/cmd/genspec-tui/internal/ux/model_follow_test.go @@ -0,0 +1,106 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package ux + +import ( + "go/token" + "os" + "path/filepath" + "testing" + + "github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux/index" + "github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux/panels" + "github.com/go-openapi/codescan/internal/scanner" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// followFixture builds a Model wired with a known spec/source index pair, ready +// to drive follow mode without a real scan. +func followFixture(t *testing.T) *Model { + t.Helper() + m := &Model{spec: panels.NewSpec(), fileView: panels.NewFileView()} + m.spec.SetSize(60, 20) + m.fileView.SetSize(60, 20) + m.spec.SetContent("line0\nline1\nline2\nline3\nline4\nline5\nline6\nline7\nline8") + m.specIndex = index.NewSpecIndex( + map[int]string{7: "/definitions/User/properties/email"}, + map[string]int{"/definitions/User/properties/email": 7}, + ) + return m +} + +func TestFollow_SourceDriven(t *testing.T) { + m := followFixture(t) + m.srcIndex = index.BuildSourceIndex([]scanner.Provenance{ + {Pointer: "/definitions/User/properties/email", Pos: token.Position{Filename: "user.go", Line: 5}}, + }) + m.currentFile = "user.go" + m.fileView.SetFile("user.go", "a\nb\nc\nd\ne\nf") + m.fileView.GotoLine(4) // 0-based row 4 == source line 5 + m.focused = paneTree + m.leftMode = modeView + + m.toggleFollow(followSource) + assert.Equal(t, followSource, m.follow, "f enters source-driven follow") + assert.Equal(t, "/definitions/User/properties/email", m.followTarget) + + // A line with no anchor at or above reports honestly — and names the cause, + // rather than flattening every miss into one opaque message. + m.fileView.GotoLine(0) // source line 1, before the first anchor (line 5) + m.syncFollowIfActive() + assert.Equal(t, noAnchorDesc, m.followTarget) + + // Moving focus off the driver exits follow. + m.focused = paneSpec + m.syncFollowIfActive() + assert.Equal(t, followOff, m.follow, "leaving the driver pane exits follow") +} + +func TestFollow_SpecDriven(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "user.go") + require.NoError(t, os.WriteFile(src, []byte("package p\n\ntype User struct{}\n"), 0o600)) + + m := &Model{spec: panels.NewSpec(), fileView: panels.NewFileView()} + m.cfg.WorkDir = dir + m.spec.SetSize(60, 20) + m.fileView.SetSize(60, 20) + m.spec.SetContent("{\n \"definitions\": {}\n}") + // The node maps to the top line of the viewport (YOffset 0). + m.specIndex = index.NewSpecIndex( + map[int]string{0: "/definitions/User"}, + map[string]int{"/definitions/User": 0}, + ) + m.srcIndex = index.BuildSourceIndex([]scanner.Provenance{ + {Pointer: "/definitions/User", Pos: token.Position{Filename: src, Line: 3}}, + }) + m.focused = paneSpec + + m.toggleFollow(followSpec) + assert.Equal(t, followSpec, m.follow) + assert.Equal(t, src, m.currentFile, "the source follower loads the producing file") + assert.Equal(t, paneSpec, m.focused, "the driver keeps focus") + assert.Contains(t, m.followTarget, "/definitions/User") + assert.Contains(t, m.followTarget, "user.go:3") + + // f again toggles off. + m.toggleFollow(followSpec) + assert.Equal(t, followOff, m.follow) + assert.Empty(t, m.followTarget) +} + +func TestFollow_ExitClearsState(t *testing.T) { + m := followFixture(t) + m.srcIndex = index.BuildSourceIndex(nil) + m.follow = followSpec + m.followTarget = "something" + + m.exitFollow() + assert.Equal(t, followOff, m.follow) + assert.Empty(t, m.followTarget) + // Idempotent. + m.exitFollow() + assert.Equal(t, followOff, m.follow) +} diff --git a/cmd/genspec-tui/internal/ux/model_gosyntax_test.go b/cmd/genspec-tui/internal/ux/model_gosyntax_test.go new file mode 100644 index 00000000..e926e0c9 --- /dev/null +++ b/cmd/genspec-tui/internal/ux/model_gosyntax_test.go @@ -0,0 +1,250 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package ux + +import ( + "go/token" + "os" + "path/filepath" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux/panels" + "github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux/theme" + "github.com/go-openapi/codescan/internal/parsers/grammar" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +const annotatedGo = `package main + +// swagger:model User +// A user of the system. +type User struct { + Name string ` + "`json:\"name\"`" + ` +} +` + +// goViewerModel opens path in a model whose source pane is sized to render. +func goViewerModel(t *testing.T, path string) *Model { + t.Helper() + m := &Model{spec: panels.NewSpec(), fileView: panels.NewFileView()} + m.fileView.SetSize(60, 20) + m.loadFileQuietly(path) + + return m +} + +// The tree opens whatever the user points at. A Go tokenizer has nothing true +// to say about go.mod or a golden JSON fixture, so those stay plain. +func TestGoSpans_OnlyGoFiles(t *testing.T) { + assert.NotEmpty(t, goSpans("x.go", []byte(annotatedGo))) + + for _, name := range []string{"go.mod", "golden.json", "README.md", "Makefile"} { + assert.Nil(t, goSpans(name, []byte(annotatedGo)), name) + } +} + +// A missing or unreadable file leaves an error message in the buffer, and the +// spans from the PREVIOUS file must not colour it by their columns. +func TestGoSpans_ReadErrorClearsSpans(t *testing.T) { + m := goViewerModel(t, writeTempGo(t, annotatedGo)) + require.Contains(t, m.fileView.View(false, false), "swagger:model") + + m.loadFileQuietly(filepath.Join(t.TempDir(), "gone.go")) + + body := m.fileView.View(false, false) + assert.Contains(t, stripANSI(body), "error reading file") + assert.NotContains(t, body, runOpenerFor(t, theme.SyntaxKey), + "no annotation run survives into the error message") +} + +// The annotation is what the pane exists for, so it must be told apart from the +// prose beside it all the way through to the rendered output. +func TestGoSyntax_AnnotationStandsOutFromProse(t *testing.T) { + m := goViewerModel(t, writeTempGo(t, annotatedGo)) + + view := m.fileView.View(false, false) + + assert.Contains(t, view, runOpenerFor(t, theme.SyntaxKey)+"// swagger:model User", + "the annotation is highlighted as the payload") + assert.Contains(t, view, runOpenerFor(t, theme.SyntaxComment)+"// A user of the system.", + "the prose next to it is not") + assert.Contains(t, stripANSI(view), "type User struct {", "the text is unchanged") +} + +// Leaving the editor returns to the highlighted viewer. Spans computed when the +// file was LOADED colour by the old columns, which is a plausible lie. +func TestGoSyntax_RehighlightsOnLeavingEditMode(t *testing.T) { + path := writeTempGo(t, annotatedGo) + m := goViewerModel(t, path) + m.currentFile = path + require.NotNil(t, m.fileView.StartEdit()) + + // Prepend a line: every run below it is now one line off. + m.fileView.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("//")}) + m.fileView.Update(tea.KeyMsg{Type: tea.KeyEnter}) + require.True(t, m.fileView.Dirty()) + + _, _ = m.handleEditKey(tea.KeyMsg{Type: tea.KeyEsc}) + + require.False(t, m.fileView.Editing(), "esc returns to the viewer") + view := m.fileView.View(false, false) + assert.Contains(t, view, runOpenerFor(t, theme.SyntaxKey)+"// swagger:model User", + "the annotation is coloured on the line it MOVED to") +} + +// An edit that makes the file un-parseable must not blank the pane: the +// tokenizer is error tolerant precisely so highlighting survives typing. +func TestGoSyntax_SurvivesAnUnparseableEdit(t *testing.T) { + path := writeTempGo(t, annotatedGo) + m := goViewerModel(t, path) + m.currentFile = path + require.NotNil(t, m.fileView.StartEdit()) + + m.fileView.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("func f( {")}) + + _, _ = m.handleEditKey(tea.KeyMsg{Type: tea.KeyEsc}) + + assert.Contains(t, m.fileView.View(false, false), + runOpenerFor(t, theme.SyntaxKey)+"// swagger:model User") +} + +// textarea rewrites tabs as spaces on the way in. Tokenizing the FILE rather +// than the buffer put every run three columns early PER LEADING TAB, which is +// enough to cut a token in half: `int64` drew as a plain `int` and a green `64`, +// and `struct` came out in two colours. +func TestGoSyntax_TabIndentedLinesColourOnTokenBoundaries(t *testing.T) { + path := writeTempGo(t, "package p\n\ntype T struct {\n\tID int64 `json:\"id\"`\n}\n") + m := goViewerModel(t, path) + + view := m.fileView.View(false, false) + + assert.Contains(t, view, runOpenerFor(t, theme.SyntaxString)+"`json:\"id\"`", + "the string run starts at the backtick") + assert.NotContains(t, view, runOpenerFor(t, theme.SyntaxString)+"int64", + "and not one tab-expansion earlier, inside int64") +} + +// Two tabs displace twice as far, so nesting is where this first became visible. +func TestGoSyntax_NestedTabsStayOnTokenBoundaries(t *testing.T) { + path := writeTempGo(t, + "package p\n\ntype T struct {\n\tItems []struct {\n\t\tPetID int64 `json:\"petId\"`\n\t}\n}\n") + m := goViewerModel(t, path) + + view := m.fileView.View(false, false) + + assert.Contains(t, view, runOpenerFor(t, theme.SyntaxString)+"`json:\"petId\"`") + assert.Contains(t, view, runOpenerFor(t, theme.SyntaxKeyword)+"struct ", + "`struct` is one run, not split across two colours") +} + +// Against real fixture sources rather than a hand-written snippet: highlighting +// must leave every line of every file exactly as it was. +func TestE2E_GoSyntaxLeavesTheSourceIntact(t *testing.T) { + dir := filepath.Join(fixturesDir(t), "goparsing", "petstore", "models") + entries, err := os.ReadDir(dir) + require.NoError(t, err) + + var checked int + for _, e := range entries { + if e.IsDir() || filepath.Ext(e.Name()) != ".go" { + continue + } + path := filepath.Join(dir, e.Name()) + content, err := os.ReadFile(path) + require.NoError(t, err) + + m := goViewerModel(t, path) + m.fileView.SetSize(400, len(strings.Split(string(content), "\n"))+4) + + plain := stripANSI(m.fileView.View(false, false)) + for line := range strings.SplitSeq(string(content), "\n") { + if trimmed := strings.TrimSpace(line); trimmed != "" { + require.Contains(t, plain, trimmed, "%s: highlighting altered the text", e.Name()) + } + } + checked++ + } + require.Positive(t, checked, "the fixture directory must hold Go sources") +} + +// runOpenerFor is the SGR prefix a syntax class renders with — assertions match +// on it because a run also carries whatever follows it up to the next run. +func runOpenerFor(t *testing.T, kind theme.SyntaxKind) string { + t.Helper() + rendered := theme.Syntax(kind).Render("x") + prefix, _, found := strings.Cut(rendered, "x") + require.True(t, found) + require.NotEmpty(t, prefix, "colour is off — see TestMain") + + return prefix +} + +// A file with Windows line endings must load with the SAME line numbering the +// file has. The editor widget treats a lone CR as a line break, so without +// normalising, the buffer gains a blank line after every real one and every +// coordinate below the first CR — anchors, marks, follow targets — points a +// growing distance away from what it names. +// +// This reproduces on any platform: the endings are in the fixture, not the +// checkout. +func TestCRLF_LineNumberingSurvivesWindowsEndings(t *testing.T) { + crlf := strings.ReplaceAll(annotatedGo, "\n", "\r\n") + path := filepath.Join(t.TempDir(), "crlf.go") + require.NoError(t, os.WriteFile(path, []byte(crlf), 0o600)) + + m := goViewerModel(t, path) + + // Modulo the widget's tab expansion, which is separate and documented. + wantLines := strings.Split(strings.ReplaceAll(annotatedGo, "\t", " "), "\n") + gotLines := strings.Split(m.fileView.Value(), "\n") + require.Equal(t, len(wantLines), len(gotLines), "the buffer gained or lost lines") + assert.Equal(t, wantLines, gotLines, "line for line, the buffer is the file") + + assert.NotContains(t, m.fileView.Value(), "\r", "no carriage return survives into the buffer") + assert.NotContains(t, m.currentSource, "\r", "nor into the coordinates diagnostics resolve against") +} + +// ...and the highlighting keyed on those lines still lands: the annotation is +// on line 2 of the file, so it must be on line 2 of the pane. +func TestCRLF_HighlightingLandsOnTheRightLine(t *testing.T) { + crlf := strings.ReplaceAll(annotatedGo, "\n", "\r\n") + path := filepath.Join(t.TempDir(), "crlf.go") + require.NoError(t, os.WriteFile(path, []byte(crlf), 0o600)) + + m := goViewerModel(t, path) + + assert.Contains(t, m.fileView.View(false, false), + runOpenerFor(t, theme.SyntaxKey)+"// swagger:model User") +} + +// A diagnostic names a line in the FILE; with CRLF unhandled it marked a line +// that had drifted away from the one it named. +func TestCRLF_DiagnosticsMarkTheLineTheyName(t *testing.T) { + crlf := strings.ReplaceAll(annotatedGo, "\n", "\r\n") + path := filepath.Join(t.TempDir(), "crlf.go") + require.NoError(t, os.WriteFile(path, []byte(crlf), 0o600)) + + m := goViewerModel(t, path) + m.currentFile = path + // Line 6 is the `Name string` field, indented with a tab. + m.diags = []grammar.Diagnostic{{ + Pos: token.Position{Filename: path, Line: 6, Column: 2}, + Severity: grammar.SeverityWarning, + Code: grammar.CodeContextInvalid, + Message: "invented for this test", + }} + m.refreshSource() + + marks := m.sourceMarks() + require.Len(t, marks, 1) + assert.Equal(t, 5, marks[0].Line, "0-based line 5 is the file's line 6") + + buffer := strings.Split(m.fileView.Value(), "\n") + require.Less(t, marks[0].Line, len(buffer)) + assert.Contains(t, buffer[marks[0].Line], "Name string", "the mark is on the line it names") +} diff --git a/cmd/genspec-tui/internal/ux/model_gutter_test.go b/cmd/genspec-tui/internal/ux/model_gutter_test.go new file mode 100644 index 00000000..af8ecf7e --- /dev/null +++ b/cmd/genspec-tui/internal/ux/model_gutter_test.go @@ -0,0 +1,169 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package ux + +import ( + "go/token" + "os" + "path/filepath" + "testing" + + "github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux/index" + "github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux/panels" + "github.com/go-openapi/codescan/internal/scanner" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// S13 — the link gutter (design §6.5): which lines actually lead somewhere. + +func TestGutter_MarksAnchorsAndRefs(t *testing.T) { + m := newRefModel(t) + m.srcIndex = index.BuildSourceIndex([]scanner.Provenance{ + {Pointer: "/definitions/User", Pos: token.Position{Filename: "user.go", Line: 3}}, + {Pointer: "/definitions/Team", Pos: token.Position{Filename: "team.go", Line: 3}}, + }) + m.rebuildGutters() + + g := m.specGutter() + require.NotNil(t, g) + + // Anchored definitions carry the anchor marker at their declaration line. + assert.Equal(t, panels.GutterAnchor, g[rmLineUserDecl], "/definitions/User is anchored") + + // Local $refs carry the ref marker. + assert.Equal(t, panels.GutterRef, g[rmLineLead], "the lead property's $ref is followable") + assert.Equal(t, panels.GutterRef, g[rmLineItemsRef]) + assert.Equal(t, panels.GutterRef, g[rmLineRespRef]) +} + +// An external $ref is not followable, so marking it would promise a jump that +// Enter cannot make. +func TestGutter_ExternalRefsAreNotMarked(t *testing.T) { + m := newRefModel(t) + m.srcIndex = index.BuildSourceIndex(nil) + m.rebuildGutters() + + _, marked := m.specGutter()[rmLineLogo] + assert.False(t, marked, "the external ref line carries no marker") +} + +// Nothing to say means no gutter at all, so the pane keeps its full width. +func TestGutter_NilWhenNothingLinks(t *testing.T) { + m := &Model{spec: panels.NewSpec(), fileView: panels.NewFileView()} + m.spec.SetSize(60, 10) + m.specJSON = `{"swagger":"2.0"}` + m.refreshSpec() + + assert.Nil(t, m.specGutter(), "no provenance and no refs → no gutter") +} + +// Only nodes with an anchor of their OWN are marked. Marking everything that +// merely resolves through an ancestor would dot nearly every line. +func TestGutter_OnlyExactAnchors(t *testing.T) { + m := newRefModel(t) + m.srcIndex = index.BuildSourceIndex([]scanner.Provenance{ + {Pointer: "/definitions/User", Pos: token.Position{Filename: "user.go", Line: 3}}, + }) + m.rebuildGutters() + + g := m.specGutter() + require.Equal(t, panels.GutterAnchor, g[rmLineUserDecl]) + + // The name property resolves to User by nearest-ancestor, but has no anchor + // of its own, so it stays unmarked. + _, marked := g[rmLineUserName] + assert.False(t, marked, + "a node that only resolves through an ancestor is not marked") + + // Sanity: it really does resolve, which is what makes the distinction real. + _, ok := m.srcIndex.PositionFor("/definitions/User/properties/name") + require.True(t, ok) +} + +// The gutter holds rendered line numbers, so a format toggle must rebuild it. +func TestGutter_RebuiltOnFormatToggle(t *testing.T) { + m := newRefModel(t) + m.specYAML = "definitions:\n Team:\n properties:\n lead:\n $ref: '#/definitions/User'\n User: {}\n" + m.srcIndex = index.BuildSourceIndex([]scanner.Provenance{ + {Pointer: "/definitions/User", Pos: token.Position{Filename: "user.go", Line: 3}}, + }) + m.rebuildGutters() + jsonGutter := m.specGutter() + require.Equal(t, panels.GutterAnchor, jsonGutter[rmLineUserDecl]) + + m.setSpecFormat("YAML") + + yamlGutter := m.specGutter() + require.NotNil(t, yamlGutter) + assert.NotEqual(t, jsonGutter, yamlGutter, "the YAML render puts the nodes on other lines") + + userLine, ok := m.specIndex.LineForPointer("/definitions/User") + require.True(t, ok) + assert.Equal(t, panels.GutterAnchor, yamlGutter[userLine], "marked at its YAML line") +} + +func TestGutter_SourceAnchorsFollowTheOpenFile(t *testing.T) { + dir := t.TempDir() + userGo := filepath.Join(dir, "user.go") + teamGo := filepath.Join(dir, "team.go") + require.NoError(t, os.WriteFile(userGo, []byte("package p\n\ntype User struct{}\n"), 0o600)) + require.NoError(t, os.WriteFile(teamGo, []byte("package p\n\ntype Team struct{}\n"), 0o600)) + + m := &Model{spec: panels.NewSpec(), fileView: panels.NewFileView()} + m.cfg.WorkDir = dir + m.spec.SetSize(60, 10) + m.fileView.SetSize(60, 10) + m.srcIndex = index.BuildSourceIndex([]scanner.Provenance{ + {Pointer: "/definitions/User", Pos: token.Position{Filename: userGo, Line: 3}}, + {Pointer: "/definitions/Team", Pos: token.Position{Filename: teamGo, Line: 3}}, + }) + + m.loadFileQuietly(userGo) + assert.Equal(t, map[int]bool{3: true}, m.srcIndex.AnchorLines(userGo)) + + // Opening another file must swap the anchor set, not keep the old one. + m.loadFileQuietly(teamGo) + assert.Equal(t, map[int]bool{3: true}, m.srcIndex.AnchorLines(teamGo)) + assert.Nil(t, m.srcIndex.AnchorLines(filepath.Join(dir, "nope.go")), + "a file that produced nothing has no anchors") +} + +// Against a real scan: the gutter must mark real nodes, and every marked line +// must genuinely be navigable. +func TestE2E_GutterMarksNavigableLines(t *testing.T) { + m := scanPetstore(t) + + g := m.specGutter() + require.NotEmpty(t, g, "a real scan produces both anchors and refs") + + lines := specLines(m) + var anchors, refs int + for line, marker := range g { + require.Less(t, line, len(lines), "marker is inside the document") + + switch marker { + case panels.GutterAnchor: + anchors++ + ptr, ok := m.specIndex.PointerAt(line) + require.True(t, ok, "line %d has a pointer", line) + _, hasSrc := m.srcIndex.PositionFor(ptr) + assert.True(t, hasSrc, "anchored line %d (%s) leads to source", line, ptr) + case panels.GutterRef: + refs++ + site, ok := m.refIndex.RefAt(line) + require.True(t, ok, "line %d holds a $ref", line) + assert.True(t, site.Target.Local, "marked refs are followable") + _, ok = m.specIndex.LineForPointer(site.Target.Pointer) + assert.True(t, ok, "line %d resolves to a rendered node", line) + default: + t.Fatalf("unexpected marker %q on line %d", marker, line) + } + } + + assert.Positive(t, anchors, "the petstore has anchored definitions") + assert.Positive(t, refs, "and followable refs") + assert.Less(t, len(g), len(lines), + "the gutter is a hint, not a mark on every line — otherwise it says nothing") +} diff --git a/cmd/genspec-tui/internal/ux/model_join_test.go b/cmd/genspec-tui/internal/ux/model_join_test.go new file mode 100644 index 00000000..5440a4ed --- /dev/null +++ b/cmd/genspec-tui/internal/ux/model_join_test.go @@ -0,0 +1,490 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package ux + +import ( + "go/token" + "os" + "path/filepath" + "strconv" + "testing" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + + "github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux/index" + "github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux/panels" + "github.com/go-openapi/codescan/internal/scanner" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// C8 — the JOIN, exercised through the model. +// +// The two indexes have their own unit tests; what those cannot show is whether +// the model wires them together correctly. So these tests synthesize a scan — +// a rendered spec body plus the []scanner.Provenance a real scan would emit — +// build the indexes with the REAL builders, put the source files on disk, and +// then assert where the follower actually lands. +// +// The provenance set deliberately anchors only definitions and properties, the +// way codescan does (anchors-only emission, design §3.4): no anchor on +// `…/email/type`, none on a struct's closing brace. That is what makes +// nearest-ancestor (spec→source) and nearest-enclosing (source→spec) resolution +// observable rather than incidental. + +// joinSpecJSON is what the spec pane renders. Line numbers are load-bearing — +// see joinLine* below. +const joinSpecJSON = `{ + "definitions": { + "Address": { + "properties": { + "city": { + "type": "string" + } + } + }, + "User": { + "properties": { + "email": { + "type": "string" + }, + "manager": { + "$ref": "#/definitions/User" + } + } + } + } +}` + +// 0-based rendered lines of the nodes these tests navigate to. +const ( + joinLineAddress = 2 + joinLineCity = 4 + joinLineUser = 9 + joinLineEmail = 11 + joinLineEmailType = 12 // NOT anchored — resolves up to the email property + joinLineManager = 14 + joinLineManagerRef = 15 // NOT anchored — resolves up to the manager property +) + +// The synthesized source. Anchors point at the 1-based lines noted alongside. +const ( + joinUserGo = `package models + +// User is a user. +type User struct { + Email string + Manager *User +} +` + joinAddressGo = `package models + +// Address is an address. +type Address struct { + City string +} +` +) + +// 1-based source lines, matching the files above. +const ( + joinSrcUserDecl = 4 + joinSrcEmail = 5 + joinSrcManager = 6 + joinSrcUserClose = 7 // NOT anchored — resolves back to the manager field + joinSrcAddressDecl = 4 + joinSrcCity = 5 +) + +type joinFixture struct { + m *Model + userGo string + addrGo string + specLine func(ptr string) int +} + +// newJoinFixture writes the source files to a temp dir, builds the real spec +// index from the rendered bytes, and installs the provenance a scan would emit. +func newJoinFixture(t *testing.T) joinFixture { + t.Helper() + + dir := t.TempDir() + userGo := filepath.Join(dir, "user.go") + addrGo := filepath.Join(dir, "address.go") + require.NoError(t, os.WriteFile(userGo, []byte(joinUserGo), 0o600)) + require.NoError(t, os.WriteFile(addrGo, []byte(joinAddressGo), 0o600)) + + // searchInput must be a real textinput: a zero-value one panics on Focus, + // and production always builds it in New(). + m := &Model{spec: panels.NewSpec(), fileView: panels.NewFileView(), searchInput: textinput.New()} + m.cfg.WorkDir = dir + m.spec.SetSize(60, 10) + m.fileView.SetSize(60, 10) + m.specJSON = joinSpecJSON + m.refreshSpec() // builds the real SpecIndex from the rendered bytes + + m.srcIndex = index.BuildSourceIndex([]scanner.Provenance{ + {Pointer: "/definitions/User", Pos: token.Position{Filename: userGo, Line: joinSrcUserDecl}}, + {Pointer: "/definitions/User/properties/email", Pos: token.Position{Filename: userGo, Line: joinSrcEmail}}, + {Pointer: "/definitions/User/properties/manager", Pos: token.Position{Filename: userGo, Line: joinSrcManager}}, + {Pointer: "/definitions/Address", Pos: token.Position{Filename: addrGo, Line: joinSrcAddressDecl}}, + {Pointer: "/definitions/Address/properties/city", Pos: token.Position{Filename: addrGo, Line: joinSrcCity}}, + }) + + return joinFixture{ + m: m, userGo: userGo, addrGo: addrGo, + specLine: func(ptr string) int { + line, ok := m.specIndex.LineForPointer(ptr) + require.True(t, ok, "pointer %q must be in the rendered spec", ptr) + return line + }, + } +} + +// driveSpec moves the spec cursor to `line` (what driveSpecToSource reads) and +// re-mirrors the follower. +func (f joinFixture) driveSpec(line int) { + f.m.spec.SetCursor(line) + f.m.syncFollowIfActive() +} + +// driveSource moves the source nav cursor to a 1-based source line and +// re-mirrors the follower. +func (f joinFixture) driveSource(srcLine int) { + f.m.fileView.GotoLine(srcLine - 1) + f.m.syncFollowIfActive() +} + +func TestJoin_SpecIndexMatchesFixture(t *testing.T) { + f := newJoinFixture(t) + + // Guards the hand-counted line constants the rest of the file relies on. + for ptr, want := range map[string]int{ + "/definitions/Address": joinLineAddress, + "/definitions/Address/properties/city": joinLineCity, + "/definitions/User": joinLineUser, + "/definitions/User/properties/email": joinLineEmail, + "/definitions/User/properties/email/type": joinLineEmailType, + "/definitions/User/properties/manager": joinLineManager, + "/definitions/User/properties/manager/$ref": joinLineManagerRef, + } { + assert.Equal(t, want, f.specLine(ptr), "rendered line of %s", ptr) + } +} + +func TestJoin_SpecToSource_LandsOnTheAnchoredLine(t *testing.T) { + f := newJoinFixture(t) + f.m.focused = paneSpec + f.m.spec.SetCursor(joinLineEmail) + + f.m.toggleFollow(followSpec) + + require.Equal(t, followSpec, f.m.follow) + assert.Equal(t, f.userGo, f.m.currentFile, "the follower opened the producing file") + assert.Equal(t, joinSrcEmail-1, f.m.fileView.CurrentLine(), + "the follower parked on the email field's source line") + assert.Equal(t, paneSpec, f.m.focused, "the driver keeps focus") + assert.Contains(t, f.m.followTarget, "user.go:"+strconv.Itoa(joinSrcEmail)) +} + +// The spec has far more nodes than codescan anchors, so most lines resolve via +// nearest-ancestor. `…/email/type` has no anchor of its own. +func TestJoin_SpecToSource_NearestAncestor(t *testing.T) { + f := newJoinFixture(t) + f.m.focused = paneSpec + f.m.toggleFollow(followSpec) + + f.driveSpec(joinLineEmailType) + + assert.Equal(t, joinSrcEmail-1, f.m.fileView.CurrentLine(), + "an unanchored child resolves to its nearest anchored ancestor") + assert.Contains(t, f.m.followTarget, "/definitions/User/properties/email/type", + "the status names the node under the cursor, not the ancestor it resolved through") + assert.Contains(t, f.m.followTarget, "user.go:"+strconv.Itoa(joinSrcEmail)) +} + +func TestJoin_SpecToSource_SwitchesFileWhenTheTargetMoves(t *testing.T) { + f := newJoinFixture(t) + f.m.focused = paneSpec + f.m.spec.SetCursor(joinLineEmail) + f.m.toggleFollow(followSpec) + require.Equal(t, f.userGo, f.m.currentFile) + + f.driveSpec(joinLineCity) + + assert.Equal(t, f.addrGo, f.m.currentFile, "the follower reopened the other file") + assert.Equal(t, joinSrcCity-1, f.m.fileView.CurrentLine()) +} + +// Walking the driver must re-mirror the follower each time, not just on entry. +func TestJoin_SpecToSource_TracksTheDriver(t *testing.T) { + f := newJoinFixture(t) + f.m.focused = paneSpec + f.m.toggleFollow(followSpec) + + for _, step := range []struct { + specLine int + wantSrcLine int + wantFile string + }{ + {joinLineAddress, joinSrcAddressDecl, f.addrGo}, + {joinLineCity, joinSrcCity, f.addrGo}, + {joinLineUser, joinSrcUserDecl, f.userGo}, + {joinLineEmail, joinSrcEmail, f.userGo}, + {joinLineManager, joinSrcManager, f.userGo}, + {joinLineManagerRef, joinSrcManager, f.userGo}, // unanchored → manager + } { + f.driveSpec(step.specLine) + assert.Equal(t, step.wantFile, f.m.currentFile, "spec line %d", step.specLine) + assert.Equal(t, step.wantSrcLine-1, f.m.fileView.CurrentLine(), "spec line %d", step.specLine) + } +} + +func TestJoin_SourceToSpec_LandsOnTheProducedNode(t *testing.T) { + f := newJoinFixture(t) + f.m.loadFileQuietly(f.userGo) + f.m.focused, f.m.leftMode = paneTree, modeView + f.m.fileView.GotoLine(joinSrcEmail - 1) + + f.m.toggleFollow(followSource) + + require.Equal(t, followSource, f.m.follow) + assert.Equal(t, "/definitions/User/properties/email", f.m.followTarget) + assert.Equal(t, joinLineEmail, f.m.spec.CursorLine(), + "the spec follower centred on the produced node") +} + +// A source line between anchors resolves to the nearest anchor at or above it. +func TestJoin_SourceToSpec_NearestEnclosing(t *testing.T) { + f := newJoinFixture(t) + f.m.loadFileQuietly(f.userGo) + f.m.focused, f.m.leftMode = paneTree, modeView + f.m.toggleFollow(followSource) + + f.driveSource(joinSrcUserClose) // the struct's closing brace: no anchor + + assert.Equal(t, "/definitions/User/properties/manager", f.m.followTarget, + "an unanchored line resolves to the nearest enclosing anchor") + assert.Equal(t, joinLineManager, f.m.spec.CursorLine()) +} + +func TestJoin_SourceToSpec_TracksTheDriver(t *testing.T) { + f := newJoinFixture(t) + f.m.loadFileQuietly(f.userGo) + f.m.focused, f.m.leftMode = paneTree, modeView + f.m.toggleFollow(followSource) + + for _, step := range []struct { + srcLine int + wantPtr string + wantSpec int + }{ + {joinSrcUserDecl, "/definitions/User", joinLineUser}, + {joinSrcEmail, "/definitions/User/properties/email", joinLineEmail}, + {joinSrcManager, "/definitions/User/properties/manager", joinLineManager}, + {joinSrcUserClose, "/definitions/User/properties/manager", joinLineManager}, + } { + f.driveSource(step.srcLine) + assert.Equal(t, step.wantPtr, f.m.followTarget, "source line %d", step.srcLine) + assert.Equal(t, step.wantSpec, f.m.spec.CursorLine(), "source line %d", step.srcLine) + } +} + +// A source line ABOVE every anchor in the file has no enclosing node. The +// follower must hold rather than snap to something arbitrary. +func TestJoin_SourceToSpec_AboveFirstAnchorHolds(t *testing.T) { + f := newJoinFixture(t) + f.m.loadFileQuietly(f.userGo) + f.m.focused, f.m.leftMode = paneTree, modeView + f.m.fileView.GotoLine(joinSrcEmail - 1) + f.m.toggleFollow(followSource) + before := f.m.spec.TopLine() + + f.driveSource(1) // `package models`, above the first anchor + + assert.Equal(t, noAnchorDesc, f.m.followTarget) + assert.Equal(t, before, f.m.spec.TopLine(), "the follower held its position") +} + +// A rescan rebuilds both indexes; follow must re-resolve against the NEW spec +// rather than keep pointing at a line number from the old render. +func TestJoin_FollowSurvivesRescan(t *testing.T) { + f := newJoinFixture(t) + f.m.loadFileQuietly(f.userGo) + f.m.focused, f.m.leftMode = paneTree, modeView + f.m.fileView.GotoLine(joinSrcEmail - 1) + f.m.toggleFollow(followSource) + require.Equal(t, joinLineEmail, f.m.spec.CursorLine()) + + // The same spec with a definition inserted ABOVE User, so every User node + // shifts down. A stale line number would now point at the wrong node. + grown := `{ + "definitions": { + "AAA": { + "properties": { + "zzz": { + "type": "string" + } + } + }, + "Address": { + "properties": { + "city": { + "type": "string" + } + } + }, + "User": { + "properties": { + "email": { + "type": "string" + }, + "manager": { + "$ref": "#/definitions/User" + } + } + } + } +}` + + _, _ = f.m.Update(scanResultMsg{ + json: grown, + provenance: []scanner.Provenance{ + {Pointer: "/definitions/User/properties/email", Pos: token.Position{Filename: f.userGo, Line: joinSrcEmail}}, + }, + }) + + newLine, ok := f.m.specIndex.LineForPointer("/definitions/User/properties/email") + require.True(t, ok) + require.NotEqual(t, joinLineEmail, newLine, "precondition: the node moved in the new render") + + assert.Equal(t, "/definitions/User/properties/email", f.m.followTarget) + assert.Equal(t, newLine, f.m.spec.CursorLine(), + "follow re-resolved against the rebuilt index") +} + +func TestJoin_FollowExits(t *testing.T) { + // Source-driven: the file viewer is the driver. + sourceDriven := func(t *testing.T) joinFixture { + t.Helper() + f := newJoinFixture(t) + f.m.loadFileQuietly(f.userGo) + f.m.focused, f.m.leftMode = paneTree, modeView + f.m.fileView.GotoLine(joinSrcEmail - 1) + f.m.toggleFollow(followSource) + require.Equal(t, followSource, f.m.follow) + return f + } + + // Spec-driven, with the left pane back on the tree. `/` and `o` are only + // reachable from here: handleViewerKey swallows every key it does not own, + // so neither reaches the global bindings while a file is open. + specDriven := func(t *testing.T) joinFixture { + t.Helper() + f := newJoinFixture(t) + f.m.focused, f.m.leftMode = paneSpec, modeBrowse + f.m.spec.SetCursor(joinLineEmail) + f.m.toggleFollow(followSpec) + require.Equal(t, followSpec, f.m.follow) + return f + } + + t.Run("opening search", func(t *testing.T) { + f := specDriven(t) + _, _, handled := f.m.handleSearchControl(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}) + require.True(t, handled) + assert.Equal(t, followOff, f.m.follow, "search takes over the spec pane") + }) + + t.Run("opening options", func(t *testing.T) { + f := specDriven(t) + _, _ = f.m.handleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'o'}}) + require.True(t, f.m.optionsOpen) + assert.Equal(t, followOff, f.m.follow, "a rescan is about to invalidate the indexes") + }) + + t.Run("starting to edit", func(t *testing.T) { + f := sourceDriven(t) + _ = f.m.fileView.StartEdit() + f.m.syncFollowIfActive() + assert.Equal(t, followOff, f.m.follow, "positions go stale once the buffer is edited") + }) + + t.Run("second f", func(t *testing.T) { + f := sourceDriven(t) + f.m.toggleFollow(followSource) + assert.Equal(t, followOff, f.m.follow) + assert.Empty(t, f.m.followTarget) + }) + + t.Run("focus change", func(t *testing.T) { + f := sourceDriven(t) + f.m.focused = paneDiag + f.m.syncFollowIfActive() + assert.Equal(t, followOff, f.m.follow, "the driver pane lost focus") + }) +} + +// The read-only viewer shadows only the keys it owns; everything else reaches +// the global bindings. Reading source is exactly when you want to rescan or +// flip JSON↔YAML, and those used to be dead for as long as a file was open. +func TestJoin_ViewerPassesGlobalKeysThrough(t *testing.T) { + viewing := func(t *testing.T) joinFixture { + t.Helper() + f := newJoinFixture(t) + f.m.loadFileQuietly(f.userGo) + f.m.focused, f.m.leftMode = paneTree, modeView + return f + } + + t.Run("slash opens search", func(t *testing.T) { + f := viewing(t) + _, _ = f.m.handleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}) + assert.True(t, f.m.searching) + }) + + t.Run("o opens the options popup", func(t *testing.T) { + f := viewing(t) + _, _ = f.m.handleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'o'}}) + assert.True(t, f.m.optionsOpen) + }) + + t.Run("format toggle works while reading source", func(t *testing.T) { + f := viewing(t) + f.m.specYAML = "definitions: {}\n" + _, _ = f.m.handleKey(tea.KeyMsg{Type: tea.KeyCtrlY}) + assert.Equal(t, "YAML", f.m.spec.Format()) + }) + + t.Run("r triggers a rescan", func(t *testing.T) { + f := viewing(t) + _, cmd := f.m.handleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}}) + assert.NotNil(t, cmd, "a scan command was issued") + assert.True(t, f.m.scanning) + }) + + // ...but the keys the viewer owns still belong to it. + t.Run("j still moves the nav line", func(t *testing.T) { + f := viewing(t) + before := f.m.fileView.CurrentLine() + _, _ = f.m.handleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}}) + assert.Equal(t, before+1, f.m.fileView.CurrentLine()) + assert.False(t, f.m.searching) + }) + + t.Run("esc returns to the tree", func(t *testing.T) { + f := viewing(t) + _, _ = f.m.handleKey(tea.KeyMsg{Type: tea.KeyEsc}) + assert.Equal(t, modeBrowse, f.m.leftMode) + }) + + // Tab / c / ctrl+q were duplicated in both handlers; the global copies now + // serve the viewer too, and must behave identically. + t.Run("tab still changes focus", func(t *testing.T) { + f := viewing(t) + _, _ = f.m.handleKey(tea.KeyMsg{Type: tea.KeyTab}) + assert.Equal(t, paneSpec, f.m.focused) + }) +} diff --git a/cmd/genspec-tui/internal/ux/model_options_test.go b/cmd/genspec-tui/internal/ux/model_options_test.go new file mode 100644 index 00000000..10d85ae6 --- /dev/null +++ b/cmd/genspec-tui/internal/ux/model_options_test.go @@ -0,0 +1,236 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package ux + +import ( + "reflect" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/go-openapi/codescan" + + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// optionsDeliberatelyOmitted lists exported bools on codescan.Options that must +// NOT get an overlay row, with the reason. Anything not here and not in the +// overlay fails TestOptions_OverlayCoversEveryBoolKnob. +var optionsDeliberatelyOmitted = map[string]string{ //nolint:gochecknoglobals // table for the drift guard + "DescWithRef": "deprecated in favour of EmitRefSiblings", + "Debug": "deprecated no-op; the stderr logger was retired", +} + +func newOptionsModel(t *testing.T) *Model { + t.Helper() + m := New(codescan.Options{WorkDir: t.TempDir(), Packages: []string{"./..."}}) + t.Cleanup(m.Close) + m.height = 40 // tall enough that nothing scrolls unless a test wants it to + + return m +} + +// The overlay silently fell eleven knobs behind the v0.36 feature streak +// because nothing failed when they landed. This is what fails now. +func TestOptions_OverlayCoversEveryBoolKnob(t *testing.T) { + m := newOptionsModel(t) + + covered := make(map[*bool]string, len(m.optToggles)) + for _, tg := range m.optToggles { + covered[tg.ptr] = tg.label + } + + cfg := reflect.ValueOf(&m.cfg).Elem() + typ := cfg.Type() + for i := range typ.NumField() { + f := typ.Field(i) + if !f.IsExported() || f.Type.Kind() != reflect.Bool { + continue + } + + ptr, ok := cfg.Field(i).Addr().Interface().(*bool) + require.True(t, ok, "Options.%s", f.Name) + + if label, inOverlay := covered[ptr]; inOverlay { + assert.Equal(t, f.Name, label, + "the row for Options.%s should be labelled with the field name", f.Name) + + continue + } + if _, omitted := optionsDeliberatelyOmitted[f.Name]; omitted { + continue + } + + t.Errorf("codescan.Options.%s is a boolean knob with no row in the options "+ + "overlay. Add one (grouped), or add it to optionsDeliberatelyOmitted "+ + "with a reason.", f.Name) + } +} + +// The omission list must not rot either: an entry naming a field that no longer +// exists, or one that has since been given a row, is stale. +func TestOptions_OmissionListIsCurrent(t *testing.T) { + m := newOptionsModel(t) + + labelled := make(map[string]bool, len(m.optToggles)) + for _, tg := range m.optToggles { + labelled[tg.label] = true + } + + typ := reflect.TypeOf(m.cfg) + for name, reason := range optionsDeliberatelyOmitted { + f, ok := typ.FieldByName(name) + assert.True(t, ok, "optionsDeliberatelyOmitted names Options.%s, which no longer exists", name) + assert.Equal(t, reflect.Bool, f.Type.Kind(), "Options.%s is not a bool", name) + assert.False(t, labelled[name], "Options.%s is both omitted and in the overlay", name) + assert.NotEmpty(t, reason, "Options.%s is omitted without a reason", name) + } +} + +// Every row must point into m.cfg — a row bound to a stray variable would +// toggle nothing, and the scan would ignore it. +func TestOptions_EveryRowPointsIntoTheConfig(t *testing.T) { + m := newOptionsModel(t) + + inCfg := make(map[*bool]bool) + cfg := reflect.ValueOf(&m.cfg).Elem() + for i := range cfg.NumField() { + if f := cfg.Type().Field(i); !f.IsExported() || f.Type.Kind() != reflect.Bool { + continue + } + if ptr, ok := cfg.Field(i).Addr().Interface().(*bool); ok { + inCfg[ptr] = true + } + } + + seen := make(map[string]bool, len(m.optToggles)) + for _, tg := range m.optToggles { + assert.True(t, inCfg[tg.ptr], "row %q is not bound to a codescan.Options field", tg.label) + assert.NotEmpty(t, tg.desc, "row %q has no description", tg.label) + assert.NotEmpty(t, tg.group, "row %q has no group", tg.label) + assert.False(t, seen[tg.label], "row %q appears twice", tg.label) + seen[tg.label] = true + } +} + +// Rows are stored flat but rendered grouped, so each group must appear as one +// contiguous run — otherwise a header would be emitted twice. +func TestOptions_GroupsAreContiguous(t *testing.T) { + m := newOptionsModel(t) + + var order []string + started := make(map[string]bool) + last := "" + for _, tg := range m.optToggles { + if tg.group == last { + continue + } + assert.False(t, started[tg.group], "group %q is split across the list", tg.group) + started[tg.group] = true + order = append(order, tg.group) + last = tg.group + } + + assert.Len(t, order, optionGroupCount, "optionGroupCount must match the groups in use") +} + +func TestOptions_DependentRowSaysWhyItIsInert(t *testing.T) { + m := newOptionsModel(t) + m.cfg.ScanModels = false + + view := stripANSI(m.optionsView()) + assert.Contains(t, view, "(needs ScanModels)", + "PruneUnusedModels must say it does nothing without ScanModels") + + m.cfg.ScanModels = true + assert.NotContains(t, stripANSI(m.optionsView()), "(needs ScanModels)", + "...and stop saying so once the dependency holds") +} + +// The inverse form: EmitXGoType is suppressed BY SkipExtensions rather than +// requiring it. +func TestOptions_InverseDependency(t *testing.T) { + m := newOptionsModel(t) + m.cfg.SkipExtensions = true + + view := stripANSI(m.optionsView()) + assert.Contains(t, view, "(moot: SkipExtensions)") + + m.cfg.SkipExtensions = false + assert.NotContains(t, stripANSI(m.optionsView()), "(moot: SkipExtensions)") +} + +func TestOptions_ViewShowsGroupsAndRows(t *testing.T) { + m := newOptionsModel(t) + + view := stripANSI(m.optionsView()) + + for _, g := range []string{groupScope, groupRefs, groupNaming, groupDocs, groupTypes} { + assert.Contains(t, view, g, "group header") + } + for _, label := range []string{"ScanModels", "CleanGoDoc", "EmitXGoType", "AfterDeclComments"} { + assert.Contains(t, view, label) + } +} + +// A short terminal must scroll rather than overflow, and the cursor must stay +// on screen wherever it is. +func TestOptions_ScrollsOnAShortTerminal(t *testing.T) { + m := newOptionsModel(t) + m.height = 16 // ~6 visible rows + + full, _ := m.optionsLines() + require.Greater(t, len(full), m.optionsVisibleRows(), "precondition: the list overflows") + + // Walk to the last row; it must be visible at the end. + for range len(m.optToggles) { + _, _ = m.handleOptionsKey(tea.KeyMsg{Type: tea.KeyDown}) + } + last := m.optToggles[len(m.optToggles)-1] + assert.Contains(t, stripANSI(m.optionsView()), last.label, + "the cursor row must be inside the scrolled window") + + // ...and the first row is no longer shown. + assert.NotContains(t, stripANSI(m.optionsView()), m.optToggles[0].label) +} + +func TestOptions_ToggleAndApply(t *testing.T) { + m := newOptionsModel(t) + m.optionsOpen = true + require.False(t, m.cfg.ScanModels) + + _, _ = m.handleOptionsKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}}) + assert.True(t, m.cfg.ScanModels, "space toggles the row under the cursor") + assert.True(t, m.optDirty) + + _, cmd := m.handleOptionsKey(tea.KeyMsg{Type: tea.KeyEsc}) + assert.False(t, m.optionsOpen) + assert.NotNil(t, cmd, "a changed option triggers a rescan on close") +} + +func TestOptions_CloseWithoutChangesDoesNotRescan(t *testing.T) { + m := newOptionsModel(t) + m.optionsOpen = true + + _, cmd := m.handleOptionsKey(tea.KeyMsg{Type: tea.KeyEsc}) + + assert.False(t, m.optionsOpen) + assert.Nil(t, cmd, "nothing changed, so nothing to re-run") +} + +// Guards against a row whose description repeats its label, which reads as +// noise in a list this long. +func TestOptions_DescriptionsAddInformation(t *testing.T) { + m := newOptionsModel(t) + + for _, tg := range m.optToggles { + assert.NotEqual(t, strings.ToLower(tg.label), strings.ToLower(tg.desc), + "row %q", tg.label) + // The modal is as wide as its widest row, so descriptions are kept + // terse rather than clipped at render time. + assert.LessOrEqual(t, len(tg.desc), 40, "row %q description is too long for the modal", tg.label) + } +} diff --git a/cmd/genspec-tui/internal/ux/model_paging_test.go b/cmd/genspec-tui/internal/ux/model_paging_test.go new file mode 100644 index 00000000..6f971fe2 --- /dev/null +++ b/cmd/genspec-tui/internal/ux/model_paging_test.go @@ -0,0 +1,138 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package ux + +import ( + "os" + "path/filepath" + "strconv" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/go-openapi/codescan" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// Writing the keymap out is what exposed these: laid side by side, three panes +// were missing the paging the spec pane had, and `h` turned out to be taken. + +// `h` is advertised in the header, so no pane may shadow it — least of all the +// source tree, which is where the app starts. The tree's vim-style h/l aliases +// for collapse/expand gave way to the arrows, which always worked. +func TestPaging_TreeDoesNotShadowTheHelpKey(t *testing.T) { + m := New(codescan.Options{WorkDir: t.TempDir(), Packages: []string{"./..."}}) + t.Cleanup(m.Close) + m.width, m.height = 100, 40 + m.focused, m.leftMode = paneTree, modeBrowse + + _, _ = m.handleKey(keyRune('h')) + + assert.True(t, m.helpOpen, "h opens the help in the pane the app starts in") +} + +// The arrows still collapse and expand, and the help says so. +func TestPaging_TreeArrowsAreDocumented(t *testing.T) { + body := stripANSI(strings.Join(helpLines(), "\n")) + assert.Contains(t, body, "← →", "collapse/expand must be discoverable now that h/l are gone") +} + +// viewerModel opens a long file in the read-only viewer. +func viewerModel(t *testing.T, lines int) *Model { + t.Helper() + + var b strings.Builder + for i := range lines { + b.WriteString("line " + strconv.Itoa(i) + "\n") + } + path := filepath.Join(t.TempDir(), "long.go") + require.NoError(t, os.WriteFile(path, []byte(b.String()), 0o600)) + + m := newHelpModel(t) + m.topH = 20 + m.fileView.SetSize(80, 20) + m.loadFileQuietly(path) + m.focused, m.leftMode = paneTree, modeView + + return m +} + +// A 500-line Go file one keypress at a time was the worst instance of the gap. +func TestPaging_FileViewer(t *testing.T) { + m := viewerModel(t, 500) + page := m.fileView.VisibleRows() + require.Positive(t, page) + + _, _ = m.handleKey(tea.KeyMsg{Type: tea.KeyPgDown}) + assert.Equal(t, page, m.fileView.CurrentLine()) + + _, _ = m.handleKey(tea.KeyMsg{Type: tea.KeyPgUp}) + assert.Zero(t, m.fileView.CurrentLine()) + + _, _ = m.handleKey(tea.KeyMsg{Type: tea.KeyEnd}) + assert.Equal(t, m.fileView.LastLine(), m.fileView.CurrentLine()) + + _, _ = m.handleKey(tea.KeyMsg{Type: tea.KeyHome}) + assert.Zero(t, m.fileView.CurrentLine()) +} + +// Paging must not disturb the keys the viewer already owned. +func TestPaging_ViewerKeysStillWork(t *testing.T) { + m := viewerModel(t, 50) + + _, _ = m.handleKey(tea.KeyMsg{Type: tea.KeyDown}) + assert.Equal(t, 1, m.fileView.CurrentLine()) + + _, _ = m.handleKey(keyRune('i')) + assert.True(t, m.fileView.Editing()) + + _, _ = m.handleKey(tea.KeyMsg{Type: tea.KeyEsc}) + assert.False(t, m.fileView.Editing()) +} + +func TestPaging_OptionsPopup(t *testing.T) { + m := newOptionsModel(t) + m.optionsOpen = true + last := len(m.optToggles) - 1 + + _, _ = m.handleOptionsKey(tea.KeyMsg{Type: tea.KeyEnd}) + assert.Equal(t, last, m.optCursor) + + _, _ = m.handleOptionsKey(tea.KeyMsg{Type: tea.KeyHome}) + assert.Zero(t, m.optCursor) + + _, _ = m.handleOptionsKey(tea.KeyMsg{Type: tea.KeyPgDown}) + assert.Positive(t, m.optCursor) + assert.LessOrEqual(t, m.optCursor, last, "paging never runs off the end") + + // Clamped rather than wrapping, at both ends. + for range len(m.optToggles) { + _, _ = m.handleOptionsKey(tea.KeyMsg{Type: tea.KeyPgDown}) + } + assert.Equal(t, last, m.optCursor) + for range len(m.optToggles) { + _, _ = m.handleOptionsKey(tea.KeyMsg{Type: tea.KeyPgUp}) + } + assert.Zero(t, m.optCursor) +} + +// Every navigable pane now pages. Stated as one test so a pane added later has +// an obvious place to be listed — and an obvious reason to support it. +func TestPaging_EveryNavigablePaneSupportsIt(t *testing.T) { + body := stripANSI(strings.Join(helpLines(), "\n")) + + for _, section := range []string{"spec pane", "source tree", "file viewer", "diagnostics"} { + idx := strings.Index(body, section) + require.Positive(t, idx, "section %q", section) + + rest := body[idx:] + if next := strings.Index(rest[len(section):], "\n\n"); next > 0 { + rest = rest[:len(section)+next] + } + assert.Contains(t, rest, "pgup", "section %q must offer paging", section) + assert.Contains(t, rest, "home", "section %q must offer home/end", section) + } +} diff --git a/cmd/genspec-tui/internal/ux/model_refs_test.go b/cmd/genspec-tui/internal/ux/model_refs_test.go new file mode 100644 index 00000000..0d6f6979 --- /dev/null +++ b/cmd/genspec-tui/internal/ux/model_refs_test.go @@ -0,0 +1,401 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package ux + +import ( + "testing" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + + "github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux/panels" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// D2 — find-references cycling and go-to-definition, through the model. +// +// The spec pane carries a real line cursor, so "the node under the cursor" means +// exactly that. Tests park it with SetCursor and assert on CursorLine. + +// refModelSpec references /definitions/User from three places and carries one +// external ref. Line numbers below are load-bearing. +const refModelSpec = `{ + "definitions": { + "Team": { + "properties": { + "lead": { + "$ref": "#/definitions/User" + }, + "logo": { + "$ref": "https://example.com/logo.json#/Logo" + }, + "members": { + "items": { + "$ref": "#/definitions/User" + }, + "type": "array" + } + } + }, + "User": { + "properties": { + "name": { + "type": "string" + } + } + } + }, + "paths": { + "/pets": { + "get": { + "responses": { + "200": { + "schema": { + "$ref": "#/definitions/User" + } + } + } + } + } + } +}` + +const ( + rmLineLead = 5 + rmLineLogo = 8 + rmLineItemsRef = 12 + rmLineUserDecl = 18 + rmLineUserName = 20 + rmLineRespRef = 32 +) + +func newRefModel(t *testing.T) *Model { + t.Helper() + m := &Model{ + spec: panels.NewSpec(), + fileView: panels.NewFileView(), + searchInput: textinput.New(), + } + m.spec.SetSize(60, 10) + m.fileView.SetSize(60, 10) + m.specJSON = refModelSpec + m.focused = paneSpec + m.refreshSpec() + + return m +} + +func TestRefs_FixtureLines(t *testing.T) { + m := newRefModel(t) + + // Guards the hand-counted constants the rest of the file relies on. + for ptr, want := range map[string]int{ + "/definitions/User": rmLineUserDecl, + "/definitions/User/properties/name": rmLineUserName, + "/definitions/Team/properties/lead": rmLineLead - 1, + "/paths/~1pets/get/responses/200/schema": rmLineRespRef - 1, + } { + got, ok := m.specIndex.LineForPointer(ptr) + require.True(t, ok, "pointer %q", ptr) + assert.Equal(t, want, got, "rendered line of %s", ptr) + } + require.Equal(t, 4, m.refIndex.Len(), "three local refs plus the external one") +} + +func TestRefs_CycleForward(t *testing.T) { + m := newRefModel(t) + m.spec.SetCursor(rmLineUserDecl) // park on the definition + + require.Nil(t, m.cycleRefs(+1)) + assert.Equal(t, rmLineLead, m.spec.CursorLine(), "first use") + assert.Contains(t, m.refStatus, "ref 1/3") + assert.Contains(t, m.refStatus, "/definitions/Team/properties/lead") + + require.Nil(t, m.cycleRefs(+1)) + assert.Equal(t, rmLineItemsRef, m.spec.CursorLine(), "second use") + assert.Contains(t, m.refStatus, "ref 2/3") + + require.Nil(t, m.cycleRefs(+1)) + assert.Equal(t, rmLineRespRef, m.spec.CursorLine(), "third use") + assert.Contains(t, m.refStatus, "ref 3/3") + + // Wraps back to the first. + require.Nil(t, m.cycleRefs(+1)) + assert.Equal(t, rmLineLead, m.spec.CursorLine(), "wrapped") + assert.Contains(t, m.refStatus, "ref 1/3") +} + +func TestRefs_CycleBackwardEntersAtTheLastSite(t *testing.T) { + m := newRefModel(t) + m.spec.SetCursor(rmLineUserDecl) + + require.Nil(t, m.cycleRefs(-1)) + assert.Equal(t, rmLineRespRef, m.spec.CursorLine(), + "a backward step into a fresh cycle enters at the last site") + assert.Contains(t, m.refStatus, "ref 3/3") + + require.Nil(t, m.cycleRefs(-1)) + assert.Contains(t, m.refStatus, "ref 2/3") +} + +// The cursor need not be on the definition line itself: a node inside the +// definition resolves up to it, the way the rest of the tool resolves pointers. +func TestRefs_CycleFromInsideTheDefinition(t *testing.T) { + m := newRefModel(t) + m.spec.SetCursor(rmLineUserName) // /definitions/User/properties/name + + require.Nil(t, m.cycleRefs(+1)) + assert.Contains(t, m.refStatus, "ref 1/3 of /definitions/User", + "an inner node cycles the enclosing definition's uses") +} + +// Scrolling away ends the cycle: the next F3 asks about wherever the user now +// is, rather than continuing to walk the previous definition's uses. +func TestRefs_ScrollingAwayStartsAFreshCycle(t *testing.T) { + m := newRefModel(t) + m.spec.SetCursor(rmLineUserDecl) + require.Nil(t, m.cycleRefs(+1)) + require.Contains(t, m.refStatus, "ref 1/3") + + // The user scrolls to a node nothing references. + m.spec.SetCursor(rmLineLogo) + require.NotNil(t, m.cycleRefs(+1), "a failed start returns the notice-clearing cmd") + assert.Contains(t, m.notice, "nothing references") + assert.Empty(t, m.refStatus, + "the previous cycle's status must not linger while the user is elsewhere") + assert.Empty(t, m.refAnchor) +} + +// Line 0 is the opening brace, which carries no pointer at all — a different +// miss from "this node has no references", and it must say so. +func TestRefs_CycleOnAnUnindexedLine(t *testing.T) { + m := newRefModel(t) + m.spec.SetCursor(0) + + require.NotNil(t, m.cycleRefs(+1)) + assert.Equal(t, noNodeDesc, m.notice) + assert.Empty(t, m.refStatus) +} + +func TestRefs_NothingReferencesTheNode(t *testing.T) { + m := newRefModel(t) + m.spec.SetCursor(rmLineLogo) // the external-ref property: nothing points here + + require.NotNil(t, m.cycleRefs(+1)) + assert.Contains(t, m.notice, "nothing references") + assert.Empty(t, m.refStatus, "no cycle was started") +} + +// The Phase-D keys belong to the spec pane. From anywhere else they must fall +// through untouched — Enter in particular still has to open a file in the tree. +func TestRefs_KeysAreSpecPaneOnly(t *testing.T) { + for _, p := range []pane{paneTree, paneDiag} { + m := newRefModel(t) + m.spec.SetCursor(rmLineUserDecl) + m.focused = p + + for _, msg := range []tea.KeyMsg{ + {Type: tea.KeyF3}, {Type: tea.KeyF15}, {Type: tea.KeyEnter}, + } { + _, _ = m.handleKey(msg) + } + + assert.Empty(t, m.refStatus, "pane %d", p) + assert.Empty(t, m.notice, "pane %d", p) + } +} + +func TestRefs_KeyBindings(t *testing.T) { + // shift+F3 reaches bubbletea v1 as F15 (no Shift modifier on Key; xterm + // maps shift+F1..F12 onto F13..F24). Both spellings must step backward. + for _, c := range []struct { + name string + msg tea.KeyMsg + want string + }{ + {"f3", tea.KeyMsg{Type: tea.KeyF3}, "ref 1/3"}, + {"f15 (shift+f3 on xterm)", tea.KeyMsg{Type: tea.KeyF15}, "ref 3/3"}, + } { + t.Run(c.name, func(t *testing.T) { + m := newRefModel(t) + m.spec.SetCursor(rmLineUserDecl) + + _, _ = m.handleKey(c.msg) + + assert.Contains(t, m.refStatus, c.want) + }) + } +} + +func TestRefs_GotoDefinition(t *testing.T) { + m := newRefModel(t) + m.spec.SetCursor(rmLineLead) // a local $ref + + _, _ = m.handleKey(tea.KeyMsg{Type: tea.KeyEnter}) + + assert.Equal(t, rmLineUserDecl, m.spec.CursorLine(), + "enter followed the $ref to its definition") + assert.Equal(t, "→ /definitions/User", m.notice) +} + +// Regression: a jump CENTRES its target, so after F3 the line we landed on is +// not the top of the viewport. Enter must still act on where the jump put the +// user — otherwise the headline workflow (find a use, then go back to the +// definition) reports "no $ref on this line". +func TestRefs_CycleThenGotoDefinition(t *testing.T) { + m := newRefModel(t) + m.spec.SetCursor(rmLineUserDecl) + + _, _ = m.handleKey(tea.KeyMsg{Type: tea.KeyF3}) + require.Contains(t, m.refStatus, "ref 1/3") + require.NotEqual(t, rmLineLead, m.spec.TopLine(), + "precondition: the jump centred, so TopLine is NOT the target line") + require.Equal(t, rmLineLead, m.spec.CursorLine(), "but the cursor is") + + _, _ = m.handleKey(tea.KeyMsg{Type: tea.KeyEnter}) + + assert.Equal(t, "→ /definitions/User", m.notice) + assert.Equal(t, rmLineUserDecl, m.spec.CursorLine(), + "and the definition we landed on becomes the new cursor, so Enter can chain") +} + +// Moving the cursor off the site the cycle parked it on ends the cycle: the +// next F3 asks about the node the user is now on. +func TestRefs_MovingTheCursorEndsTheCycle(t *testing.T) { + m := newRefModel(t) + m.spec.SetCursor(rmLineUserDecl) + _, _ = m.handleKey(tea.KeyMsg{Type: tea.KeyF3}) + require.Equal(t, rmLineLead, m.spec.CursorLine()) + require.Contains(t, m.refStatus, "ref 1/3") + + // One line down and we are off the site, so the cycle cannot continue. + // (That line is inside Team, which nothing references — hence no new cycle.) + _, _ = m.handleKey(tea.KeyMsg{Type: tea.KeyDown}) + require.Equal(t, rmLineLead+1, m.spec.CursorLine()) + + _, _ = m.handleKey(tea.KeyMsg{Type: tea.KeyF3}) + assert.NotContains(t, m.refStatus, "ref 2/3", "the cycle did not continue") + assert.Contains(t, m.notice, "nothing references") + + // Park inside User again and F3 re-anchors there, from the first site. + m.spec.SetCursor(rmLineUserName) + _, _ = m.handleKey(tea.KeyMsg{Type: tea.KeyF3}) + assert.Contains(t, m.refStatus, "ref 1/3 of /definitions/User", + "a fresh cycle, re-anchored on the node now under the cursor") +} + +// The spec pane is navigable in its own right: the cursor keys move the cursor, +// and paging moves it with the view rather than leaving it behind off screen. +func TestSpecNav_CursorKeys(t *testing.T) { + m := newRefModel(t) + m.topH = 10 // gives handleSpecNav a page size + m.spec.SetCursor(rmLineUserDecl) + + for _, c := range []struct { + name string + msg tea.KeyMsg + want int + }{ + {"down", tea.KeyMsg{Type: tea.KeyDown}, rmLineUserDecl + 1}, + {"j", tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}}, rmLineUserDecl + 2}, + {"up", tea.KeyMsg{Type: tea.KeyUp}, rmLineUserDecl + 1}, + {"k", tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'k'}}, rmLineUserDecl}, + {"home", tea.KeyMsg{Type: tea.KeyHome}, 0}, + {"end", tea.KeyMsg{Type: tea.KeyEnd}, m.spec.LastLine()}, + } { + t.Run(c.name, func(t *testing.T) { + _, _ = m.handleKey(c.msg) + assert.Equal(t, c.want, m.spec.CursorLine()) + }) + } + + // Paging moves the cursor too, so F3/Enter never act on an off-screen node. + m.spec.SetCursor(0) + _, _ = m.handleKey(tea.KeyMsg{Type: tea.KeyPgDown}) + assert.Positive(t, m.spec.CursorLine(), "page down carried the cursor with it") +} + +// The nav keys belong to the spec pane only — j/k must still drive the tree and +// the diagnostics list. +func TestSpecNav_OnlyFromTheSpecPane(t *testing.T) { + m := newRefModel(t) + m.spec.SetCursor(rmLineUserDecl) + m.focused = paneDiag + + _, _ = m.handleKey(tea.KeyMsg{Type: tea.KeyDown}) + + assert.Equal(t, rmLineUserDecl, m.spec.CursorLine(), "the spec cursor did not move") +} + +func TestRefs_GotoDefinitionEdges(t *testing.T) { + t.Run("external ref is reported, not guessed at", func(t *testing.T) { + m := newRefModel(t) + m.spec.SetCursor(rmLineLogo) + before := m.spec.TopLine() + + _, _ = m.handleKey(tea.KeyMsg{Type: tea.KeyEnter}) + + assert.Contains(t, m.notice, "external ref") + assert.Contains(t, m.notice, "logo.json") + assert.Equal(t, before, m.spec.TopLine(), "the viewport held") + }) + + t.Run("no $ref on this line", func(t *testing.T) { + m := newRefModel(t) + m.spec.SetCursor(rmLineUserDecl) + + _, _ = m.handleKey(tea.KeyMsg{Type: tea.KeyEnter}) + + assert.Equal(t, "no $ref on this line", m.notice) + }) +} + +// The cycle holds rendered line numbers, so anything that replaces the render +// must drop it rather than let F3 jump to a line that no longer means anything. +func TestRefs_CycleResets(t *testing.T) { + active := func(t *testing.T) *Model { + t.Helper() + m := newRefModel(t) + m.specYAML = "definitions:\n Team:\n properties:\n lead:\n $ref: '#/definitions/User'\n User: {}\n" + m.spec.SetCursor(rmLineUserDecl) + require.Nil(t, m.cycleRefs(+1)) + require.NotEmpty(t, m.refStatus) + return m + } + + t.Run("format toggle", func(t *testing.T) { + m := active(t) + m.setSpecFormat("YAML") + assert.Empty(t, m.refStatus) + assert.Empty(t, m.refAnchor) + }) + + t.Run("rescan", func(t *testing.T) { + m := active(t) + _, _ = m.Update(scanResultMsg{json: refModelSpec}) + assert.Empty(t, m.refStatus) + assert.Empty(t, m.refAnchor) + }) + + t.Run("esc", func(t *testing.T) { + m := active(t) + _, _ = m.handleKey(tea.KeyMsg{Type: tea.KeyEsc}) + assert.Empty(t, m.refStatus) + assert.Empty(t, m.refAnchor) + }) + + t.Run("entering follow mode", func(t *testing.T) { + m := active(t) + m.toggleFollow(followSpec) + assert.Empty(t, m.refStatus, "follow drives the viewport; the cycle's lines go stale") + }) + + t.Run("opening search", func(t *testing.T) { + m := active(t) + _, _, handled := m.handleSearchControl(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}) + require.True(t, handled) + assert.Empty(t, m.refStatus) + }) +} diff --git a/cmd/genspec-tui/internal/ux/model_rescan_test.go b/cmd/genspec-tui/internal/ux/model_rescan_test.go new file mode 100644 index 00000000..fbd41e28 --- /dev/null +++ b/cmd/genspec-tui/internal/ux/model_rescan_test.go @@ -0,0 +1,214 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package ux + +import ( + "testing" + + "github.com/charmbracelet/bubbles/textinput" + + "github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux/panels" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// B-rescan-anchor — a re-render must keep the user on the same NODE. +// +// This is the hot path: every save triggers a rescan, and live-reload is the +// tool's reason to exist. Carrying the raw line number across would slide the +// user to a different node whenever the spec gained or lost lines above them. + +// rescanBase is the starting render. +const rescanBase = `{ + "definitions": { + "Address": { + "properties": { + "city": { + "type": "string" + } + } + }, + "User": { + "properties": { + "name": { + "type": "string" + } + } + } + } +}` + +// rescanGrown is the same spec with a definition inserted ABOVE both, so every +// node below shifts down. +const rescanGrown = `{ + "definitions": { + "AAA": { + "properties": { + "zzz": { + "type": "string" + } + } + }, + "Address": { + "properties": { + "city": { + "type": "string" + } + } + }, + "User": { + "properties": { + "name": { + "type": "string" + } + } + } + } +}` + +// rescanShrunk drops User entirely — the type was deleted. +const rescanShrunk = `{ + "definitions": { + "Address": { + "properties": { + "city": { + "type": "string" + } + } + } + } +}` + +func newRescanModel(t *testing.T) *Model { + t.Helper() + m := &Model{ + spec: panels.NewSpec(), + fileView: panels.NewFileView(), + searchInput: textinput.New(), + } + // Tall enough that a node shifted by a few lines is still on screen — + // otherwise "did it avoid scrolling?" cannot be asked. + m.spec.SetSize(60, 20) + m.fileView.SetSize(60, 20) + m.focused = paneSpec + m.specJSON = rescanBase + m.refreshSpec() + + return m +} + +// parkOn puts the cursor on a pointer and returns the line it was on. +func parkOn(t *testing.T, m *Model, ptr string) int { + t.Helper() + line, ok := m.specIndex.LineForPointer(ptr) + require.True(t, ok, "pointer %q must be in the render", ptr) + m.spec.SetCursor(line) + + return line +} + +func TestRescan_KeepsTheCursorOnTheSameNode(t *testing.T) { + m := newRescanModel(t) + const ptr = "/definitions/User" + before := parkOn(t, m, ptr) + + // A rescan whose spec gained a definition above the one being read. + m.specJSON = rescanGrown + m.refreshSpec() + + after, ok := m.specIndex.LineForPointer(ptr) + require.True(t, ok) + require.NotEqual(t, before, after, "precondition: the node moved in the new render") + + assert.Equal(t, after, m.spec.CursorLine(), + "the cursor followed the node, not the line number") +} + +func TestRescan_ViaScanResultMessage(t *testing.T) { + m := newRescanModel(t) + const ptr = "/definitions/User/properties/name" + parkOn(t, m, ptr) + + // The real path a scan arrives by. + _, _ = m.Update(scanResultMsg{json: rescanGrown}) + + after, ok := m.specIndex.LineForPointer(ptr) + require.True(t, ok) + assert.Equal(t, after, m.spec.CursorLine()) +} + +// When the node is gone, land in its neighbourhood rather than somewhere +// arbitrary: the walk falls back to the nearest surviving ancestor. +func TestRescan_DeletedNodeFallsBackToItsAncestor(t *testing.T) { + m := newRescanModel(t) + parkOn(t, m, "/definitions/User/properties/name") + + m.specJSON = rescanShrunk + m.refreshSpec() + + _, gone := m.specIndex.LineForPointer("/definitions/User") + require.False(t, gone, "precondition: User was deleted") + + definitionsLine, ok := m.specIndex.LineForPointer("/definitions") + require.True(t, ok) + assert.Equal(t, definitionsLine, m.spec.CursorLine(), + "fell back to the nearest ancestor that survived") +} + +// An unchanged rescan — the common case, since most saves do not move anything +// — must not move the cursor at all. +func TestRescan_IdenticalSpecDoesNotMoveTheCursor(t *testing.T) { + m := newRescanModel(t) + before := parkOn(t, m, "/definitions/User") + topBefore := m.spec.TopLine() + + m.refreshSpec() + + assert.Equal(t, before, m.spec.CursorLine()) + assert.Equal(t, topBefore, m.spec.TopLine(), + "and the viewport did not jump either") +} + +// The restore scrolls minimally rather than centring: on the hot path, yanking +// the viewport on every save would be worse than the drift it fixes. +func TestRescan_DoesNotYankTheViewport(t *testing.T) { + m := newRescanModel(t) + parkOn(t, m, "/definitions/User") + topBefore := m.spec.TopLine() + + m.specJSON = rescanGrown + m.refreshSpec() + + // The node moved a few lines but is still on screen, so the view should be + // steady — not recentred on the cursor. + assert.Equal(t, topBefore, m.spec.TopLine(), + "the node was still visible, so nothing needed to scroll") +} + +// ...whereas an explicit format switch is a deliberate change of view, and does +// recentre. +func TestRescan_FormatSwitchRecentres(t *testing.T) { + m := newRescanModel(t) + m.specYAML = "definitions:\n Address:\n properties:\n city:\n type: string\n User:\n properties:\n name:\n type: string\n" + parkOn(t, m, "/definitions/User") + + m.setSpecFormat("YAML") + + line, ok := m.specIndex.LineForPointer("/definitions/User") + require.True(t, ok) + assert.Equal(t, line, m.spec.CursorLine(), "same node") + assert.Equal(t, max(line-(20-3)/2, 0), m.spec.TopLine(), "centred in the viewport") +} + +// The first scan has no previous index to anchor against, and must not panic or +// jump anywhere. +func TestRescan_FirstScanStartsAtTheTop(t *testing.T) { + m := &Model{spec: panels.NewSpec(), fileView: panels.NewFileView()} + m.spec.SetSize(60, 20) + + m.specJSON = rescanBase + m.refreshSpec() + + assert.Equal(t, 0, m.spec.CursorLine()) +} diff --git a/cmd/genspec-tui/internal/ux/model_syntax_test.go b/cmd/genspec-tui/internal/ux/model_syntax_test.go new file mode 100644 index 00000000..d016c9e4 --- /dev/null +++ b/cmd/genspec-tui/internal/ux/model_syntax_test.go @@ -0,0 +1,140 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package ux + +import ( + "strings" + "testing" + + "github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux/panels" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +const syntaxSpec = `{ + "swagger": "2.0", + "definitions": { + "User": { + "properties": { + "email": { + "type": "string", + "maxLength": 64 + } + } + } + } +}` + +func syntaxModel(t *testing.T) *Model { + t.Helper() + m := &Model{spec: panels.NewSpec(), fileView: panels.NewFileView()} + m.spec.SetSize(70, 24) + m.specJSON = syntaxSpec + m.refreshSpec() + + return m +} + +// The highlight index rides the same walk as the other two, so a refresh must +// install all three or none. +func TestSyntax_RefreshInstallsSpans(t *testing.T) { + m := syntaxModel(t) + + require.NotNil(t, m.specIndex) + require.NotNil(t, m.refIndex) + assert.Contains(t, stripANSI(m.spec.View(false)), `"swagger"`, + "the text is unchanged by highlighting") +} + +// An empty spec must clear the spans along with the indexes; leaving stale runs +// behind would colour the placeholder by the old document's columns. +func TestSyntax_EmptySpecClearsSpans(t *testing.T) { + m := syntaxModel(t) + m.specJSON = "" + m.refreshSpec() + + view := stripANSI(m.spec.View(false)) + assert.Contains(t, view, "(no spec generated yet)") + assert.NotContains(t, view, "swagger") +} + +// Highlighting must never alter the text — the same invariant the renderer is +// tested on, asserted here through the whole pipeline. +func TestSyntax_TextSurvivesHighlighting(t *testing.T) { + m := syntaxModel(t) + + plain := stripANSI(m.spec.View(false)) + for _, want := range []string{`"swagger": "2.0",`, `"maxLength": 64`, `"type": "string",`} { + assert.Contains(t, plain, want) + } +} + +// Precedence: the cursor and a search hit answer questions the USER asked, so +// they take the whole line rather than compete with syntax colour for it. +func TestSyntax_PrecedenceCursorThenSearchThenSyntax(t *testing.T) { + m := syntaxModel(t) + + // Both renders must contain the same visible text regardless of which + // styling layer won. + m.spec.SetCursor(1) + withCursor := stripANSI(m.spec.View(true)) + assert.Contains(t, withCursor, `"swagger": "2.0",`) + + require.Positive(t, m.spec.Search("maxLength")) + withSearch := stripANSI(m.spec.View(true)) + assert.Contains(t, withSearch, `"maxLength": 64`) + + m.spec.ClearSearch() + assert.Contains(t, stripANSI(m.spec.View(true)), `"maxLength": 64`) +} + +// A search must still count matches on the raw line: highlighting changes how a +// line is drawn, never what it contains. +func TestSyntax_SearchStillCountsMatches(t *testing.T) { + m := syntaxModel(t) + + n := m.spec.Search(`"type"`) + + assert.Equal(t, 1, n) + cur, total := m.spec.MatchInfo() + assert.Equal(t, 1, cur) + assert.Equal(t, 1, total) +} + +// The gutter is prefixed after styling, so the two must coexist. +func TestSyntax_CoexistsWithTheGutter(t *testing.T) { + m := syntaxModel(t) + m.spec.SetGutter(map[int]rune{2: panels.GutterAnchor}) + + view := stripANSI(m.spec.View(false)) + + assert.Contains(t, view, string(panels.GutterAnchor)) + assert.Contains(t, view, `"definitions"`) +} + +// Against a real scan, over both renders: the visible text must be exactly the +// document, highlighted or not. +func TestE2E_SyntaxLeavesTheSpecIntact(t *testing.T) { + m := scanPetstore(t) + m.spec.SetSize(200, 40) + + for _, format := range []string{"JSON", "YAML"} { + m.setSpecFormat(format) + body := m.specJSON + if format == "YAML" { + body = m.specYAML + } + require.NotEmpty(t, body) + + // Take a line from the middle of the document and require it to appear + // verbatim in the rendered pane. + lines := strings.Split(body, "\n") + probe := strings.TrimRight(lines[len(lines)/2], " ") + require.NotEmpty(t, strings.TrimSpace(probe)) + + m.spec.JumpTo(len(lines) / 2) + assert.Contains(t, stripANSI(m.spec.View(false)), strings.TrimSpace(probe), + "%s: highlighting altered the rendered text", format) + } +} diff --git a/cmd/genspec-tui/internal/ux/panels/diagnostics.go b/cmd/genspec-tui/internal/ux/panels/diagnostics.go new file mode 100644 index 00000000..c87f9c26 --- /dev/null +++ b/cmd/genspec-tui/internal/ux/panels/diagnostics.go @@ -0,0 +1,78 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package panels + +import ( + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + + "github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux/theme" +) + +// Diagnostics is the bottom diagnostics panel: a scrollable viewport whose +// content is composed by the model from the scan's grammar.Diagnostic slice +// (see renderDiagnostics). It stays presentation-only — the model owns the +// diagnostic data and formatting; the panel just displays and scrolls it. +type Diagnostics struct { + vp viewport.Model + w, h int + content string +} + +// NewDiagnostics returns a Diagnostics panel with placeholder content. +func NewDiagnostics() Diagnostics { + const placeholder = "(no diagnostics)" + vp := viewport.New(0, 0) + vp.SetContent(placeholder) + return Diagnostics{vp: vp, content: placeholder} +} + +// SetSize fits the panel to outer dimensions w×h (border + title reserved). +func (p *Diagnostics) SetSize(w, h int) { + p.w, p.h = w, h + p.vp.Width = max(w-2, 0) + p.vp.Height = max(h-3, 0) +} + +// SetContent replaces the rendered diagnostics text. +func (p *Diagnostics) SetContent(s string) { + p.content = s + p.vp.SetContent(s) +} + +// Content returns the raw (unwrapped) panel text, for clipboard copy. +func (p *Diagnostics) Content() string { return p.content } + +// RevealLine scrolls the minimum distance that brings the 0-based content line +// into view, and nothing at all when it is already visible. Stepping the +// selection must not shift the whole list under the reader — the same rule the +// spec pane and the source viewer follow. +func (p *Diagnostics) RevealLine(line int) { + switch { + case line < p.vp.YOffset: + p.vp.SetYOffset(line) + case line >= p.vp.YOffset+p.vp.Height: + p.vp.SetYOffset(line - p.vp.Height + 1) + } +} + +// TopLine is the 0-based index of the top visible content line. +func (p *Diagnostics) TopLine() int { return p.vp.YOffset } + +// VisibleRows is how many content lines the viewport shows, for page-sized +// cursor moves. +func (p *Diagnostics) VisibleRows() int { return max(p.vp.Height, 1) } + +// Update forwards a message to the underlying viewport (scrolling). +func (p *Diagnostics) Update(msg tea.Msg) tea.Cmd { + var cmd tea.Cmd + p.vp, cmd = p.vp.Update(msg) + return cmd +} + +// View renders the bordered panel; focused brightens the border/title. +func (p *Diagnostics) View(focused bool) string { + title := theme.Title(focused).Render("diagnostics") + return theme.Panel(p.w, p.h, focused).Render(title + "\n" + p.vp.View()) +} diff --git a/cmd/genspec-tui/internal/ux/panels/fileview.go b/cmd/genspec-tui/internal/ux/panels/fileview.go new file mode 100644 index 00000000..e23dfa99 --- /dev/null +++ b/cmd/genspec-tui/internal/ux/panels/fileview.go @@ -0,0 +1,298 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package panels + +import ( + "fmt" + "strconv" + "strings" + + "github.com/charmbracelet/bubbles/textarea" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux/theme" +) + +// FileView is the left pane's file display. It opens READ-ONLY and navigable — +// a highlighted line you move with the cursor keys and follow to the spec — and +// switches to an editable textarea on demand (`i`), returning to the viewer on +// Esc. Disk is the source of truth; saving writes back and the watcher drives +// the rescan. A VIM/VS-Code integration is the eventual full editor. +type FileView struct { + ta textarea.Model + w, h int + title string + loaded string // content as loaded/saved, for the dirty check + editing bool + navLine int // 0-based highlighted line in read-only mode + offset int // 0-based top visible line in read-only mode + + anchors map[int]bool // 1-based source lines that produced a spec node + spans map[int][]theme.Span // 0-based line → lexical runs; nil renders plain +} + +// NewFileView returns an empty viewer. +func NewFileView() FileView { + ta := textarea.New() + ta.ShowLineNumbers = true + ta.CharLimit = 0 // no limit; whole files + ta.Prompt = "" // line numbers are the gutter; no extra prompt + return FileView{ta: ta} +} + +// SetAnchors installs the source lines that produced a spec node, for the +// viewer's link gutter (design §6.5). Keyed 1-based, matching token.Position. +// A nil map renders no gutter. +func (p *FileView) SetAnchors(lines map[int]bool) { p.anchors = lines } + +// SetSpans installs the per-line lexical runs used to highlight the READ-ONLY +// viewer, keyed by 0-based line. A nil map renders the text plain, which is +// what a non-Go file gets. +// +// Edit mode is deliberately not covered: bubbles/textarea owns its own +// rendering and emits the buffer verbatim, so highlighting there would mean +// replacing the widget rather than styling its output. +func (p *FileView) SetSpans(spans map[int][]theme.Span) { p.spans = spans } + +// SetSize fits the panel to outer dimensions w×h (border + title reserved). +func (p *FileView) SetSize(w, h int) { + p.w, p.h = w, h + p.ta.SetWidth(max(w-2, 0)) + p.ta.SetHeight(max(h-3, 0)) + p.clampOffset() +} + +// SetFile loads name/content read-only at the top and marks the buffer clean. +func (p *FileView) SetFile(name, content string) { + p.title = name + p.ta.SetValue(content) + p.loaded = p.ta.Value() + p.editing = false + p.navLine = 0 + p.offset = 0 +} + +// Title returns the current file's display name. +func (p *FileView) Title() string { return p.title } + +// Value returns the current (possibly edited) buffer text. +func (p *FileView) Value() string { return p.ta.Value() } + +// Content returns the buffer text, for clipboard copy. +func (p *FileView) Content() string { return p.ta.Value() } + +// Dirty reports whether the buffer has unsaved edits. +func (p *FileView) Dirty() bool { return p.ta.Value() != p.loaded } + +// MarkClean records the current buffer as the saved baseline. +func (p *FileView) MarkClean() { p.loaded = p.ta.Value() } + +// Editing reports whether the pane is in editable mode (vs the read-only viewer). +func (p *FileView) Editing() bool { return p.editing } + +// CurrentLine returns the 0-based "current" line: the editor cursor row while +// editing, else the read-only nav line. Used by source→spec cross-ref nav. +func (p *FileView) CurrentLine() int { + if p.editing { + return p.ta.Line() + } + return p.navLine +} + +// NavUp / NavDown move the read-only nav line by one, keeping it visible. +func (p *FileView) NavUp() { p.gotoNav(p.navLine - 1) } +func (p *FileView) NavDown() { p.gotoNav(p.navLine + 1) } + +// ScrollBy moves the nav line by delta (mouse wheel in read-only mode). +func (p *FileView) ScrollBy(delta int) { p.gotoNav(p.navLine + delta) } + +// GotoLine parks the read-only nav line on the 0-based line and scrolls it to +// the VERTICAL CENTRE, clamped at the edges (design §6.1). This is the JUMP +// primitive — cross-ref landings and follow-mode mirroring — as opposed to the +// nav keys, which move the cursor one line and scroll as little as possible. +// +// The distinction matters: a follower target moves continuously as the driver +// scrolls, and minimal scrolling would pin it to whichever edge it entered from, +// whereas centring keeps it still while its context slides past. +// +// The editor cursor is synced lazily by StartEdit, so this stays cheap when +// called on every follow-mode move. +func (p *FileView) GotoLine(line int) { + p.navLine = clamp(line, 0, max(p.lineCount()-1, 0)) + visible := max(p.h-3, 1) + p.offset = clamp(p.navLine-visible/2, 0, max(p.lineCount()-visible, 0)) +} + +// VisibleRows is how many lines the read-only window shows, for page-sized +// moves of the nav line. +func (p *FileView) VisibleRows() int { return max(p.h-3, 1) } + +// LastLine is the index of the final line. +func (p *FileView) LastLine() int { return max(p.lineCount()-1, 0) } + +// StartEdit switches to the editable textarea at the current nav line. +func (p *FileView) StartEdit() tea.Cmd { + p.syncEditorCursor() + p.editing = true + return p.ta.Focus() +} + +// StopEdit leaves edit mode, parking the nav line where the cursor was. +func (p *FileView) StopEdit() { + p.navLine = p.ta.Line() + p.editing = false + p.ta.Blur() + p.clampOffset() +} + +// Focus focuses the editor when in edit mode (the read-only viewer needs no +// textarea focus). Retained for the model's focus plumbing. +func (p *FileView) Focus() tea.Cmd { + if p.editing { + return p.ta.Focus() + } + return nil +} + +// Blur removes editor focus. +func (p *FileView) Blur() { p.ta.Blur() } + +// Update forwards a message to the textarea (edit mode only; the read-only +// viewer is driven by the model's nav keys). +func (p *FileView) Update(msg tea.Msg) tea.Cmd { + if !p.editing { + return nil + } + var cmd tea.Cmd + p.ta, cmd = p.ta.Update(msg) + return cmd +} + +// View renders the bordered panel: the textarea in edit mode, a line-numbered +// read-only viewer with the nav line highlighted otherwise. A "●" marks unsaved +// edits. focused drives the border/title brightness; navActive drives the +// nav-line highlight (true when focused OR mirroring as a follow follower). +func (p *FileView) View(focused, navActive bool) string { + name := p.title + if name == "" { + name = "(no file)" + } + if p.Dirty() { + name += " ●" + } + mode := "view" + body := p.viewerBody(focused, navActive) + if p.editing { + mode = "edit" + body = p.ta.View() + } + title := theme.Title(focused).Render("file · " + name + " · " + mode) + return theme.Panel(p.w, p.h, focused).Render(title + "\n" + body) +} + +// viewerBody renders the read-only window: a right-aligned line-number gutter +// and the file text, with the nav line highlighted when navActive (the pane is +// focused, or mirroring the followed line as a follow follower). The driver pane +// keeps focus in follow mode, so focused doubles as "this is the driver line" — +// strong bar when it is, muted tint when this pane is only mirroring (§6.5). +func (p *FileView) viewerBody(focused, navActive bool) string { + inner := max(p.w-2, 0) + visible := max(p.h-3, 0) + lines := strings.Split(p.ta.Value(), "\n") + total := len(lines) + numW := len(strconv.Itoa(total)) + + // The link gutter only claims width when there is something to mark. + gutW := 0 + if len(p.anchors) > 0 { + gutW = gutterWidth + } + textW := max(inner-(numW+1)-gutW, 0) + + style := navStyle(focused) + + var b strings.Builder + end := min(p.offset+visible, total) + for i := p.offset; i < end; i++ { + prefix := p.gutterFor(i+1, gutW) + fmt.Sprintf("%*d ", numW, i+1) + + // The nav line answers a question the user asked, so it takes the whole + // row; syntax colour underneath it would fight the bar's background and + // break it up with its own resets. Same precedence as the spec pane. + row := prefix + renderSpans(lines[i], p.spans[i], textW) + if i == p.navLine && navActive { + row = style.Render(prefix + fit(lines[i], textW)) + } + b.WriteString(row) + if i < end-1 { + b.WriteString("\n") + } + } + return b.String() +} + +// gutterFor renders the link marker for a 1-based source line, or blanks of the +// same width to keep the line numbers aligned. Empty when the gutter is off. +func (p *FileView) gutterFor(srcLine, width int) string { + if width == 0 { + return "" + } + if !p.anchors[srcLine] { + return strings.Repeat(" ", width) + } + + return theme.Gutter().Render(string(GutterAnchor)) + " " +} + +// navStyle is the whole-line style for the highlighted nav line: the strong bar +// when this pane drives (the driver keeps focus in follow mode, design §6.1), a +// muted tint when it is only mirroring another pane's cursor (§6.5). +func navStyle(focused bool) lipgloss.Style { + if focused { + return theme.Selected() + } + return theme.Follower() +} + +// gotoNav clamps and sets the nav line, then re-clamps the scroll offset. +func (p *FileView) gotoNav(line int) { + p.navLine = clamp(line, 0, max(p.lineCount()-1, 0)) + p.clampOffset() +} + +// syncEditorCursor steps the textarea cursor to navLine (textarea has no +// row-setter), so toggling into edit mode keeps the line. +func (p *FileView) syncEditorCursor() { + for p.ta.Line() < p.navLine { + before := p.ta.Line() + p.ta.CursorDown() + if p.ta.Line() == before { + break + } + } + for p.ta.Line() > p.navLine { + before := p.ta.Line() + p.ta.CursorUp() + if p.ta.Line() == before { + break + } + } +} + +func (p *FileView) lineCount() int { return strings.Count(p.ta.Value(), "\n") + 1 } + +// clampOffset keeps the nav line within the visible read-only window. +func (p *FileView) clampOffset() { + visible := max(p.h-3, 1) + if p.navLine < p.offset { + p.offset = p.navLine + } + if p.navLine >= p.offset+visible { + p.offset = p.navLine - visible + 1 + } + if p.offset < 0 { + p.offset = 0 + } +} diff --git a/cmd/genspec-tui/internal/ux/panels/fileview_syntax_test.go b/cmd/genspec-tui/internal/ux/panels/fileview_syntax_test.go new file mode 100644 index 00000000..25eb8424 --- /dev/null +++ b/cmd/genspec-tui/internal/ux/panels/fileview_syntax_test.go @@ -0,0 +1,119 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package panels + +import ( + "strings" + "testing" + + "github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux/theme" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// highlightedFileView loads three lines and colours the middle one, so a test +// can tell "styled" from "not styled" without depending on the palette. +func highlightedFileView() FileView { + fv := NewFileView() + fv.SetSize(40, 10) + fv.SetFile("x.go", "package p\n\nvar x = 1\n") + fv.SetSpans(map[int][]theme.Span{ + 2: { + {Col: 1, Kind: theme.SyntaxKeyword}, + {Col: 5, Kind: theme.SyntaxPlain}, + {Col: 7, Kind: theme.SyntaxPunct}, + {Col: 9, Kind: theme.SyntaxNumber}, + }, + }) + + return fv +} + +// runOpener is the SGR prefix a syntax class renders with. Assertions match on +// it rather than on a whole styled token, because the LAST run on a line also +// absorbs the padding that fills the pane's width. +func runOpener(t *testing.T, kind theme.SyntaxKind) string { + t.Helper() + rendered := theme.Syntax(kind).Render("x") + prefix, _, found := strings.Cut(rendered, "x") + require.True(t, found) + require.NotEmpty(t, prefix, "colour is off — see TestMain") + + return prefix +} + +// The invariant the spec pane is held to, asserted here too: colouring changes +// how a line is drawn, never what it says. +func TestFileViewSyntax_TextSurvivesHighlighting(t *testing.T) { + fv := highlightedFileView() + + plain := plainOf(fv.View(true, true)) + + for _, want := range []string{"package p", "var x = 1"} { + assert.Contains(t, plain, want) + } +} + +func TestFileViewSyntax_AppliesTheStyle(t *testing.T) { + fv := highlightedFileView() + + view := fv.View(true, true) + + assert.Contains(t, view, runOpener(t, theme.SyntaxNumber)+"1", + "the number run reaches the rendered output") +} + +// The nav line answers a question the user asked, so it takes the whole row. +// Syntax colour underneath would break the bar up with its own resets. +func TestFileViewSyntax_NavLineWinsOverSyntax(t *testing.T) { + fv := highlightedFileView() + fv.GotoLine(2) // the only highlighted line + + view := fv.View(true, true) + + assert.NotContains(t, view, runOpener(t, theme.SyntaxNumber), + "the cursor row is one bar, not a patchwork of syntax runs") + assert.Contains(t, plainOf(view), "var x = 1", "and it still says what it said") +} + +// A non-Go file gets no spans at all, and must render exactly as before. +func TestFileViewSyntax_NoSpansRendersPlain(t *testing.T) { + fv := NewFileView() + fv.SetSize(40, 10) + fv.SetFile("go.mod", "module x\n\ngo 1.25\n") + + // The panel FRAME is styled either way, so compare the text rows alone. + body := fv.viewerBody(false, false) + + assert.Equal(t, plainOf(body), body, "nothing in an unhighlighted viewer is styled") + assert.Contains(t, body, "module x") +} + +// Highlighting must not disturb the link gutter, which is prefixed before it. +func TestFileViewSyntax_CoexistsWithTheGutter(t *testing.T) { + fv := highlightedFileView() + fv.SetAnchors(map[int]bool{3: true}) // anchors are 1-based + + plain := plainOf(fv.View(false, false)) + + assert.Contains(t, plain, string(GutterAnchor)) + assert.Contains(t, plain, "var x = 1") +} + +// Spans are keyed by line, so scrolling must not shift them: the run installed +// for line 2 has to still land on line 2 once line 2 is drawn from an offset. +func TestFileViewSyntax_SurvivesScrolling(t *testing.T) { + fv := NewFileView() + fv.SetSize(40, 7) // 4 visible rows out of 6 lines, so the window must move + fv.SetFile("x.go", "aaa\nbbb\nZZZ\nddd\neee\n") + fv.SetSpans(map[int][]theme.Span{2: {{Col: 1, Kind: theme.SyntaxNumber}}}) + + fv.GotoLine(3) // scrolls the window down, with the cursor NOT on line 2 + view := fv.View(false, false) + require.Contains(t, plainOf(view), "ZZZ", "the target line is on screen") + + assert.Contains(t, view, runOpener(t, theme.SyntaxNumber)+"ZZZ", + "the run must follow its line, not the window") + assert.Equal(t, 1, strings.Count(plainOf(view), "ZZZ")) +} diff --git a/cmd/genspec-tui/internal/ux/panels/fileview_test.go b/cmd/genspec-tui/internal/ux/panels/fileview_test.go new file mode 100644 index 00000000..19131ff7 --- /dev/null +++ b/cmd/genspec-tui/internal/ux/panels/fileview_test.go @@ -0,0 +1,91 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package panels + +import ( + "testing" + + "github.com/go-openapi/testify/v2/assert" +) + +func newLoadedFileView() FileView { + fv := NewFileView() + fv.SetSize(40, 10) + fv.SetFile("x.go", "line1\nline2\nline3\nline4") + return fv +} + +func TestFileView_ReadOnlyNav(t *testing.T) { + fv := newLoadedFileView() + + assert.False(t, fv.Editing(), "opens read-only") + assert.Equal(t, 0, fv.CurrentLine(), "starts at the top") + + fv.NavDown() + fv.NavDown() + assert.Equal(t, 2, fv.CurrentLine()) + + fv.NavUp() + assert.Equal(t, 1, fv.CurrentLine()) + + // Clamped at both ends. + fv.NavUp() + fv.NavUp() + assert.Equal(t, 0, fv.CurrentLine(), "clamped at the first line") + + for range 10 { + fv.NavDown() + } + assert.Equal(t, 3, fv.CurrentLine(), "clamped at the last line (4 lines, 0-based)") +} + +// textarea NORMALISES the text it is given — tabs become spaces — so comparing +// the buffer against the file marked every tab-indented Go file as edited the +// moment it was opened: a ● in the title, and a STALE badge on follow, with +// nothing touched. The buffer is the baseline, not the bytes on disk. +func TestFileView_NotDirtyOnOpen(t *testing.T) { + fv := NewFileView() + fv.SetSize(40, 10) + fv.SetFile("x.go", "package p\n\ntype T struct {\n\tID int64\n}\n") + + assert.False(t, fv.Dirty(), "a freshly opened file has no unsaved edits") + assert.NotContains(t, fv.View(false, false), "●") +} + +func TestFileView_GotoLine(t *testing.T) { + fv := newLoadedFileView() + fv.GotoLine(2) + assert.Equal(t, 2, fv.CurrentLine()) + + // Out-of-range is clamped, not an error. + fv.GotoLine(99) + assert.Equal(t, 3, fv.CurrentLine()) + fv.GotoLine(-5) + assert.Equal(t, 0, fv.CurrentLine()) +} + +func TestFileView_EditToggle(t *testing.T) { + fv := newLoadedFileView() + fv.GotoLine(2) + + _ = fv.StartEdit() + assert.True(t, fv.Editing(), "i enters edit mode") + assert.Equal(t, 2, fv.CurrentLine(), "editor cursor lands on the nav line") + + fv.StopEdit() + assert.False(t, fv.Editing(), "esc leaves edit mode") + assert.Equal(t, 2, fv.CurrentLine(), "nav line parks where the cursor was") +} + +func TestFileView_ViewRendersGutterAndHighlight(t *testing.T) { + fv := newLoadedFileView() + fv.GotoLine(1) + + out := fv.View(true, true) + assert.Contains(t, out, "line2", "shows file content") + assert.Contains(t, out, "view", "title marks read-only mode") + + _ = fv.StartEdit() + assert.Contains(t, fv.View(true, true), "edit", "title marks edit mode") +} diff --git a/cmd/genspec-tui/internal/ux/panels/gutter_test.go b/cmd/genspec-tui/internal/ux/panels/gutter_test.go new file mode 100644 index 00000000..ac97e3d2 --- /dev/null +++ b/cmd/genspec-tui/internal/ux/panels/gutter_test.go @@ -0,0 +1,103 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package panels + +import ( + "strings" + "testing" + + "github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux/theme" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// gutterMark is the marker as it appears in rendered output. +func gutterMark(r rune) string { return theme.Gutter().Render(string(r)) } + +func TestSpec_GutterMarksOnlyTheGivenLines(t *testing.T) { + sp := NewSpec() + sp.SetSize(40, 12) + sp.SetContent("aaa\nbbb\nccc\nddd") + + sp.SetGutter(map[int]rune{1: GutterAnchor, 2: GutterRef}) + sp.SetCursor(0) // keep the cursor off the lines under test + + view := sp.vp.View() + require.Contains(t, view, gutterMark(GutterAnchor)+" bbb") + require.Contains(t, view, gutterMark(GutterRef)+" ccc") + assert.Contains(t, view, strings.Repeat(" ", gutterWidth)+"ddd", + "unmarked lines are padded so the text stays aligned") +} + +// No gutter installed means no gutter column: the pane costs no width before a +// scan has produced anything to mark. +func TestSpec_NoGutterCostsNoWidth(t *testing.T) { + sp := NewSpec() + sp.SetSize(40, 12) + sp.SetContent("aaa\nbbb") + + // The viewport pads each line to its width, so compare prefixes: the text + // must start in column 0, not two columns in. Line 1 is used throughout so + // the always-rendered cursor (line 0) does not wrap the line under test. + sp.SetCursor(0) + secondLine := func() string { return strings.Split(sp.vp.View(), "\n")[1] } + + assert.True(t, strings.HasPrefix(secondLine(), "bbb"), + "content starts in column 0 when nothing is marked, got %q", secondLine()) + + sp.SetGutter(nil) + assert.True(t, strings.HasPrefix(secondLine(), "bbb"), + "an explicitly nil gutter costs no width either, got %q", secondLine()) + + // ...whereas installing one shifts the text right by the gutter width. + sp.SetGutter(map[int]rune{0: GutterAnchor}) + assert.True(t, strings.HasPrefix(secondLine(), strings.Repeat(" ", gutterWidth)+"bbb"), + "got %q", secondLine()) +} + +// The gutter is prefixed after highlighting, so both survive together and the +// styles still apply to the text rather than to the marker column. +func TestSpec_GutterCoexistsWithSearchAndCursor(t *testing.T) { + sp := NewSpec() + sp.SetSize(40, 12) + sp.SetContent("aaa\nbbb\nbcd") + sp.SetGutter(map[int]rune{0: GutterAnchor, 2: GutterAnchor}) + + n := sp.Search("b") + require.Equal(t, 2, n, "the gutter must not disturb match counting") + require.Equal(t, 1, sp.CursorLine(), "the search parked the cursor on the first match") + + view := sp.View(true) + assert.Contains(t, view, gutterMark(GutterAnchor), "markers survive a search render") + assert.Contains(t, view, theme.Match().Render("b"), + "a match the cursor is NOT on keeps its substring highlight") + assert.Contains(t, view, theme.Selected().Render("bbb"), + "the cursor line takes the whole-line bar, and the style wraps the text "+ + "rather than the gutter") +} + +func TestFileView_GutterMarksAnchoredLines(t *testing.T) { + fv := NewFileView() + fv.SetSize(40, 10) + fv.SetFile("x.go", "line1\nline2\nline3\nline4") + fv.SetAnchors(map[int]bool{2: true}) // 1-based, matching token.Position + + view := fv.View(false, false) + + assert.Contains(t, view, gutterMark(GutterAnchor)+" 2 line2", + "the anchored source line is marked") + assert.Contains(t, view, strings.Repeat(" ", gutterWidth)+"1 line1", + "other lines are padded, keeping the line numbers aligned") +} + +func TestFileView_NoAnchorsNoGutter(t *testing.T) { + fv := NewFileView() + fv.SetSize(40, 10) + fv.SetFile("x.go", "line1\nline2") + + view := fv.View(false, false) + + assert.NotContains(t, view, gutterMark(GutterAnchor)) + assert.Contains(t, view, "1 line1", "the line numbers keep their original column") +} diff --git a/cmd/genspec-tui/internal/ux/panels/main_test.go b/cmd/genspec-tui/internal/ux/panels/main_test.go new file mode 100644 index 00000000..68351e77 --- /dev/null +++ b/cmd/genspec-tui/internal/ux/panels/main_test.go @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package panels + +import ( + "os" + "testing" + + "github.com/charmbracelet/lipgloss" + "github.com/muesli/termenv" +) + +// TestMain forces a colour profile for the whole package's tests. +// +// lipgloss degrades to plain text when stdout is not a TTY, which `go test` +// never is — so without this every style renders identically and the panels' +// visual contracts (driver bar vs follower tint, §6.5) would be unfalsifiable: +// the assertions would pass just as happily against a panel that applied no +// style at all. +func TestMain(m *testing.M) { + lipgloss.SetColorProfile(termenv.TrueColor) + os.Exit(m.Run()) +} diff --git a/cmd/genspec-tui/internal/ux/panels/navvisuals_test.go b/cmd/genspec-tui/internal/ux/panels/navvisuals_test.go new file mode 100644 index 00000000..163836fd --- /dev/null +++ b/cmd/genspec-tui/internal/ux/panels/navvisuals_test.go @@ -0,0 +1,156 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package panels + +import ( + "strconv" + "strings" + "testing" + + "github.com/charmbracelet/lipgloss" + + "github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux/theme" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// These tests check both halves of the §6.5 contract: that the panels CHOOSE +// the right style for their role, and that the choice actually reaches the +// rendered output. The latter needs a forced colour profile — see TestMain. + +// numberedContent returns n uniquely identifiable lines. +func numberedContent(n int) string { + rows := make([]string, n) + for i := range rows { + rows[i] = "row" + strconv.Itoa(i) + } + return strings.Join(rows, "\n") +} + +func TestTheme_DriverAndFollowerAreDistinct(t *testing.T) { + assert.NotEqual(t, + theme.Selected().GetBackground(), theme.Follower().GetBackground(), + "the driver bar and the follower tint must be visually distinct (§6.5)") +} + +func TestSpec_XrefStyleFollowsFocus(t *testing.T) { + sp := NewSpec() + sp.SetSize(40, 12) + sp.SetContent("aaa\nbbb\nccc\nddd") + sp.SetCursor(1) + + // The driver pane keeps focus in follow mode, so focused == drives. + _ = sp.View(true) + assert.Equal(t, theme.Selected().GetBackground(), sp.cursorStyle().GetBackground(), + "a focused spec pane paints its xref line as the driver") + + _ = sp.View(false) + assert.Equal(t, theme.Follower().GetBackground(), sp.cursorStyle().GetBackground(), + "an unfocused spec pane is mirroring, so its xref line is a follower") +} + +// A focus change must actually REPAINT: the style is baked into the viewport +// content at render time, so a missed re-render would leave the previous role's +// colour on screen even though xrefStyle() reports the new one. +func TestSpec_FocusChangeRepaints(t *testing.T) { + sp := NewSpec() + sp.SetSize(40, 12) + sp.SetContent("aaa\nbbb\nccc\nddd") + sp.SetCursor(1) + + driverView := sp.View(true) + followerView := sp.View(false) + + require.Contains(t, driverView, theme.Selected().Render("bbb"), + "the driver's xref line reaches the rendered output") + require.Contains(t, followerView, theme.Follower().Render("bbb"), + "the follower's xref line reaches the rendered output") + assert.NotEqual(t, driverView, followerView, "the two roles must render differently") +} + +// stylePrefix returns the SGR escape sequence a style emits, isolated from any +// text. Asserting on it pins WHICH style painted a line — comparing whole views +// would not, because the border and title also change with focus and would mask +// a nav line that never changed at all. +func stylePrefix(st lipgloss.Style) string { + const sentinel = "\x00sentinel\x00" + prefix, _, _ := strings.Cut(st.Render(sentinel), sentinel) + return prefix +} + +// The same for the source viewer: the nav line's style must reach the output, +// not merely be selected. +func TestFileView_NavStyleReachesOutput(t *testing.T) { + fv := NewFileView() + fv.SetSize(40, 10) + fv.SetFile("x.go", "line1\nline2\nline3\nline4") + fv.GotoLine(1) + + driver, follower := stylePrefix(theme.Selected()), stylePrefix(theme.Follower()) + require.NotEqual(t, driver, follower, "precondition: the two styles emit different escapes") + + driverView := fv.View(true, true) + assert.Contains(t, driverView, driver, "a focused viewer paints its nav line as the driver") + assert.NotContains(t, driverView, follower) + + followerView := fv.View(false, true) + assert.Contains(t, followerView, follower, + "a mirroring viewer must not look like the pane the user is driving") + assert.NotContains(t, followerView, driver) + + // With navActive false nothing is highlighted at all, so neither shows. + plain := fv.View(false, false) + assert.NotContains(t, plain, driver) + assert.NotContains(t, plain, follower) +} + +func TestSpec_HighlightLineCenters(t *testing.T) { + sp := NewSpec() + sp.SetSize(40, 13) // viewport height = 10 + sp.SetContent(numberedContent(60)) + + sp.JumpTo(30) + assert.Equal(t, 25, sp.TopLine(), "target - height/2") + + sp.JumpTo(2) + assert.Equal(t, 0, sp.TopLine(), "clamped at the top rather than scrolling negative") +} + +func TestFileView_NavStyleFollowsFocus(t *testing.T) { + assert.Equal(t, theme.Selected().GetBackground(), navStyle(true).GetBackground(), + "a focused viewer paints its nav line as the driver") + assert.Equal(t, theme.Follower().GetBackground(), navStyle(false).GetBackground(), + "an unfocused-but-mirroring viewer paints its nav line as a follower") +} + +func TestFileView_GotoLineCenters(t *testing.T) { + fv := NewFileView() + fv.SetSize(40, 13) // visible = 10 + fv.SetFile("x.go", numberedContent(60)) + + fv.GotoLine(30) + assert.Equal(t, 30, fv.CurrentLine()) + assert.Equal(t, 25, fv.offset, "a jump centres its target, it does not merely reveal it") + + fv.GotoLine(1) + assert.Equal(t, 0, fv.offset, "clamped at the top") + fv.GotoLine(59) + assert.Equal(t, 50, fv.offset, "clamped at the bottom (60 lines - 10 visible)") +} + +// The nav keys must NOT centre: moving the cursor one line should scroll as +// little as possible, or the view lurches on every keypress. +func TestFileView_NavKeysScrollMinimally(t *testing.T) { + fv := NewFileView() + fv.SetSize(40, 13) // visible = 10 + fv.SetFile("x.go", numberedContent(60)) + + for range 10 { + fv.NavDown() + } + + assert.Equal(t, 10, fv.CurrentLine()) + assert.Equal(t, 1, fv.offset, + "the cursor stepped one line past the window, so the view scrolled by exactly one") +} diff --git a/cmd/genspec-tui/internal/ux/panels/spec.go b/cmd/genspec-tui/internal/ux/panels/spec.go new file mode 100644 index 00000000..878aa230 --- /dev/null +++ b/cmd/genspec-tui/internal/ux/panels/spec.go @@ -0,0 +1,328 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package panels + +import ( + "strings" + + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux/theme" +) + +// Spec is the right-hand generated-spec panel. It tracks the active render +// format (JSON/YAML) and an optional case-insensitive search that highlights +// matching lines and scrolls between them. +type Spec struct { + vp viewport.Model + w, h int + format string + content string // raw, unhighlighted spec text (also what Content() copies) + + query string + matches []int // indices of content lines containing the query + matchIdx int + + cursor int // 0-based content line the user is on + focused bool // last focus state seen by View; picks the cursor's style + + gutter map[int]rune // content line → link marker; nil renders no gutter at all + spans map[int][]theme.Span // content line → lexical runs; nil renders plain +} + +// Gutter markers (design §6.5): which lines actually lead somewhere. +const ( + // GutterAnchor marks a node with a source position of its OWN, so following + // it lands exactly there rather than on an ancestor. + GutterAnchor = '•' + + // GutterRef marks a followable $ref — Enter goes to its definition. + GutterRef = '→' + + // gutterWidth is the marker plus its separating space. + gutterWidth = 2 +) + +// NewSpec returns a Spec defaulting to JSON with placeholder content. +func NewSpec() Spec { + const placeholder = "(no spec generated yet)" + vp := viewport.New(0, 0) + vp.SetContent(placeholder) + return Spec{vp: vp, format: "JSON", content: placeholder} +} + +// SetSize fits the panel to outer dimensions w×h (border + title reserved). +func (p *Spec) SetSize(w, h int) { + p.w, p.h = w, h + p.vp.Width = max(w-2, 0) + p.vp.Height = max(h-3, 0) +} + +// SetFormat sets the title's format label ("JSON" or "YAML"). +func (p *Spec) SetFormat(f string) { p.format = f } + +// Format returns the active render format label. +func (p *Spec) Format() string { return p.format } + +// SetContent replaces the raw spec text, re-applying any active search. The +// cursor is CLAMPED, not reset: a rescan usually re-renders nearly the same +// document, and dropping the user back to line 0 on every save would make the +// live-reload loop unusable. Restoring it to the same NODE is the caller's job +// (see Model.refreshSpec). +func (p *Spec) SetContent(s string) { + p.content = s + p.cursor = clampSpec(p.cursor, 0, max(p.lineCount()-1, 0)) + p.render() + p.revealCursor() +} + +// Content returns the raw (unhighlighted) panel text, for clipboard copy. +func (p *Spec) Content() string { return p.content } + +// Search sets the query, highlights matching lines, moves the cursor to the +// first match, and returns the match count. Putting the CURSOR on the match +// (rather than merely scrolling to it) means every cursor-driven action — +// follow, find-references, go-to-definition — acts on what you just searched +// for. +func (p *Spec) Search(query string) int { + p.query = query + p.matchIdx = 0 + p.render() + if len(p.matches) > 0 { + p.scrollToMatch() + } + return len(p.matches) +} + +// Step moves to the next (dir +1) or previous (dir -1) match, wrapping around. +func (p *Spec) Step(dir int) { + if len(p.matches) == 0 { + return + } + p.matchIdx = (p.matchIdx + dir + len(p.matches)) % len(p.matches) + p.scrollToMatch() +} + +// ClearSearch drops the query and re-renders the plain spec. +func (p *Spec) ClearSearch() { + p.query = "" + p.matches = nil + p.matchIdx = 0 + p.render() +} + +// MatchInfo returns the 1-based current match and the total (0,0 when none). +func (p *Spec) MatchInfo() (cur, total int) { + if len(p.matches) == 0 { + return 0, 0 + } + return p.matchIdx + 1, len(p.matches) +} + +// scrollContext is how many lines of context to keep above a scrolled-to match. +const scrollContext = 2 + +// CursorLine returns the 0-based content line the user is on. This is what +// every "the node under the cursor" question resolves against. +func (p *Spec) CursorLine() int { return p.cursor } + +// TopLine returns the 0-based index of the top visible content line. +func (p *Spec) TopLine() int { return p.vp.YOffset } + +// LastLine is the index of the final content line. +func (p *Spec) LastLine() int { return max(p.lineCount()-1, 0) } + +// SetCursor parks the cursor on the 0-based line, scrolling only as far as +// needed to keep it visible. The incremental primitive. +func (p *Spec) SetCursor(line int) { + p.moveCursorTo(line) + p.revealCursor() +} + +// MoveCursor steps the cursor by delta, scrolling minimally. Used by the nav +// keys and the wheel, where a lurching viewport would be miserable. +func (p *Spec) MoveCursor(delta int) { p.SetCursor(p.cursor + delta) } + +// JumpTo parks the cursor on the line and scrolls it to the VERTICAL CENTRE, +// clamped at the edges (design §6.1). The JUMP primitive: every cross-ref +// landing comes through here — follow-mode mirroring, `g` locate, the ctrl+f +// jump, F3 cycling, go-to-definition. +// +// Centring rather than the top-biased scroll search uses: in follow mode the +// target moves continuously, so a top bias pins it against whichever edge it +// entered from and makes it jitter, instead of letting it sit still while its +// surroundings slide past. For a one-shot jump it simply shows context on both +// sides of the destination. +func (p *Spec) JumpTo(line int) { + p.moveCursorTo(line) + p.vp.SetYOffset(max(p.cursor-p.vp.Height/2, 0)) +} + +// Update forwards a message to the underlying viewport (scrolling). +func (p *Spec) Update(msg tea.Msg) tea.Cmd { + var cmd tea.Cmd + p.vp, cmd = p.vp.Update(msg) + return cmd +} + +// View renders the bordered panel; focused brightens the border/title. +// +// Focus also decides how the cross-ref line is painted: the driver pane keeps +// focus in follow mode (design §6.1), so "focused" and "is the driver" are the +// same bit. Re-render only on a focus TRANSITION — the spec can be thousands of +// lines and View runs on every message. +func (p *Spec) View(focused bool) string { + if p.focused != focused { + p.focused = focused + p.render() + } + title := theme.Title(focused).Render("spec · " + p.format) + return theme.Panel(p.w, p.h, focused).Render(title + "\n" + p.vp.View()) +} + +// SetSpans installs the per-line lexical runs used for syntax highlighting. +// A nil map renders the spec plain, which is what happens before the first scan. +func (p *Spec) SetSpans(spans map[int][]theme.Span) { + p.spans = spans + p.render() +} + +func clampSpec(v, lo, hi int) int { + if v < lo { + return lo + } + if v > hi { + return hi + } + return v +} + +// SetGutter installs the link markers, keyed by content line. A nil or empty +// map renders no gutter at all, so the pane costs no width until there is +// something to say (before the first scan, or with provenance switched off). +func (p *Spec) SetGutter(g map[int]rune) { + p.gutter = g + p.render() +} + +// moveCursorTo clamps and sets the cursor, re-rendering only when it actually +// moved (the cursor style is baked into the viewport content). +func (p *Spec) moveCursorTo(line int) { + line = clampSpec(line, 0, max(p.lineCount()-1, 0)) + if line == p.cursor { + return + } + p.cursor = line + p.render() +} + +// revealCursor scrolls the minimum distance that brings the cursor into view. +func (p *Spec) revealCursor() { + switch { + case p.cursor < p.vp.YOffset: + p.vp.SetYOffset(p.cursor) + case p.cursor >= p.vp.YOffset+p.vp.Height: + p.vp.SetYOffset(p.cursor - p.vp.Height + 1) + } +} + +func (p *Spec) lineCount() int { return strings.Count(p.content, "\n") + 1 } + +// cursorStyle is the whole-line style for the cursor: the strong bar when this +// pane drives, a muted tint when it is mirroring another pane. +func (p *Spec) cursorStyle() lipgloss.Style { + if p.focused { + return theme.Selected() + } + return theme.Follower() +} + +// render rebuilds the viewport content from the raw text, applying the active +// search highlight (per-substring), the cross-ref highlight (whole line) and +// the link gutter. The cross-ref line takes the whole-line style; search matches +// are still counted on it so n/N stays consistent. +// +// The gutter is prefixed AFTER highlighting, so the styles apply to the text the +// user searched for, not to the marker column. +func (p *Spec) render() { + needle := "" + if p.query != "" { + needle = strings.ToLower(p.query) + } + lines := strings.Split(p.content, "\n") + p.matches = p.matches[:0] + for i, ln := range lines { + isMatch := needle != "" && strings.Contains(strings.ToLower(ln), needle) + if isMatch { + p.matches = append(p.matches, i) + } + + // Precedence: cursor, then search, then syntax. The first two are + // answers to "where am I" and "what did I ask for" — questions the user + // posed — so they take the whole line rather than compete with colour + // for it. One plain line reads fine; a line wearing three styles does + // not. + switch { + case i == p.cursor: + ln = p.cursorStyle().Render(ln) + case isMatch: + ln = highlightAll(ln, p.query) + case len(p.spans) > 0: + ln = renderSpans(ln, p.spans[i], len([]rune(ln))) + } + lines[i] = p.gutterFor(i) + ln + } + p.vp.SetContent(strings.Join(lines, "\n")) +} + +// gutterFor renders line i's marker column, or blanks of the same width so the +// text stays aligned. Empty string when no gutter is installed. +func (p *Spec) gutterFor(i int) string { + if len(p.gutter) == 0 { + return "" + } + marker, ok := p.gutter[i] + if !ok { + return strings.Repeat(" ", gutterWidth) + } + + return theme.Gutter().Render(string(marker)) + " " +} + +func (p *Spec) scrollToMatch() { + if len(p.matches) == 0 { + return + } + p.moveCursorTo(p.matches[p.matchIdx]) + // keep a little context above the match, rather than centring: when + // stepping matches you want to see the ones that follow. + p.vp.SetYOffset(max(p.cursor-scrollContext, 0)) +} + +// highlightAll wraps every case-insensitive occurrence of query in line with +// the match style, preserving the original casing of the matched text. +func highlightAll(line, query string) string { + if query == "" { + return line + } + style := theme.Match() + lower := strings.ToLower(line) + lq := strings.ToLower(query) + + var b strings.Builder + for { + i := strings.Index(lower, lq) + if i < 0 { + b.WriteString(line) + break + } + b.WriteString(line[:i]) + b.WriteString(style.Render(line[i : i+len(query)])) + line = line[i+len(query):] + lower = lower[i+len(query):] + } + return b.String() +} diff --git a/cmd/genspec-tui/internal/ux/panels/spec_test.go b/cmd/genspec-tui/internal/ux/panels/spec_test.go new file mode 100644 index 00000000..6fd99d0d --- /dev/null +++ b/cmd/genspec-tui/internal/ux/panels/spec_test.go @@ -0,0 +1,82 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package panels + +import ( + "testing" + + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +func newLoadedSpec() Spec { + sp := NewSpec() + sp.SetSize(40, 12) + sp.SetContent("{\n \"a\": 1,\n \"b\": 2,\n \"c\": 3\n}") + return sp +} + +func TestSpec_CursorStartsAtTheTop(t *testing.T) { + sp := newLoadedSpec() + assert.Equal(t, 0, sp.CursorLine(), "a fresh spec puts the cursor on the first line") +} + +func TestSpec_JumpToMovesTheCursor(t *testing.T) { + sp := newLoadedSpec() + + sp.JumpTo(2) + + assert.Equal(t, 2, sp.CursorLine()) + assert.Contains(t, sp.Content(), "\"b\": 2", + "raw content is unchanged — the cursor is view-only") +} + +func TestSpec_CursorClamps(t *testing.T) { + sp := newLoadedSpec() // 5 lines + + sp.SetCursor(99) + assert.Equal(t, sp.LastLine(), sp.CursorLine(), "clamped at the last line") + + sp.SetCursor(-5) + assert.Equal(t, 0, sp.CursorLine(), "clamped at the first") + + sp.MoveCursor(+2) + assert.Equal(t, 2, sp.CursorLine()) + sp.MoveCursor(-99) + assert.Equal(t, 0, sp.CursorLine()) +} + +// Searching parks the cursor ON the match, so that follow, find-references and +// go-to-definition all act on what was just searched for. +func TestSpec_SearchMovesTheCursorToTheMatch(t *testing.T) { + sp := newLoadedSpec() + + require.Equal(t, 1, sp.Search("b")) + + assert.Equal(t, 2, sp.CursorLine(), `the line holding "b": 2`) +} + +// New content CLAMPS the cursor rather than resetting it: a rescan re-renders +// nearly the same document, and dropping to line 0 on every save would make the +// live-reload loop unusable. Restoring the same NODE is the caller's job. +func TestSpec_SetContentClampsRatherThanResets(t *testing.T) { + sp := newLoadedSpec() + sp.JumpTo(3) + + sp.SetContent("{\n \"x\": 9,\n \"y\": 8\n}") + assert.Equal(t, 3, sp.CursorLine(), "still in range, so kept") + + sp.SetContent("{\n}") + assert.Equal(t, 1, sp.CursorLine(), "clamped into the shorter document") +} + +func TestSpec_RenderPreservesContent(t *testing.T) { + sp := newLoadedSpec() + sp.JumpTo(2) // forces the styled render path + // The viewport render must still show every source line. + view := sp.vp.View() + for _, want := range []string{"\"a\": 1", "\"b\": 2", "\"c\": 3"} { + assert.Contains(t, view, want) + } +} diff --git a/cmd/genspec-tui/internal/ux/panels/syntax.go b/cmd/genspec-tui/internal/ux/panels/syntax.go new file mode 100644 index 00000000..8540e040 --- /dev/null +++ b/cmd/genspec-tui/internal/ux/panels/syntax.go @@ -0,0 +1,65 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package panels + +import ( + "strings" + + "github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux/theme" +) + +// renderSpans colours one raw line according to the lexical runs on it. +// +// The ORDER of operations is the whole point. Truncation happens on the raw +// text, at rune boundaries, before any escape exists; only then is each run +// wrapped in its style. Colour-then-truncate — what you are forced into when a +// highlighting library hands back a finished string — cuts through escape +// sequences and drops their resets, which is how a highlighted pane ends up +// bleeding colour across the rest of the screen. +// +// spans record only where each run starts, so a run extends to the next span's +// column. width <= 0 renders nothing; no spans renders the raw (fitted) text. +func renderSpans(raw string, spans []theme.Span, width int) string { + if width <= 0 { + return "" + } + if len(spans) == 0 { + return fit(raw, width) + } + + text := fit(raw, width) // raw, still — the ellipsis is plain by design + runes := []rune(text) + + var b strings.Builder + for i, sp := range spans { + start := sp.Col - 1 // spans are 1-based + if start >= len(runes) { + break + } + if start < 0 { + start = 0 + } + + end := len(runes) + if i+1 < len(spans) { + end = min(spans[i+1].Col-1, len(runes)) + } + if end <= start { + continue + } + + // Anything before the first run (indentation) is emitted unstyled. + if i == 0 && start > 0 { + b.WriteString(string(runes[:start])) + } + b.WriteString(theme.Syntax(sp.Kind).Render(string(runes[start:end]))) + } + + // A trailing remainder can only appear if the line ends after the last run. + if last := spans[len(spans)-1]; last.Col-1 < len(runes) { + return b.String() + } + + return string(runes) +} diff --git a/cmd/genspec-tui/internal/ux/panels/syntax_test.go b/cmd/genspec-tui/internal/ux/panels/syntax_test.go new file mode 100644 index 00000000..2b4e9c48 --- /dev/null +++ b/cmd/genspec-tui/internal/ux/panels/syntax_test.go @@ -0,0 +1,98 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package panels + +import ( + "strings" + "testing" + + "github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux/theme" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// plainOf strips SGR escapes, leaving the text a user actually sees. +func plainOf(s string) string { + var b strings.Builder + for i := 0; i < len(s); i++ { + if s[i] == 0x1b { + for i < len(s) && s[i] != 'm' { + i++ + } + + continue + } + b.WriteByte(s[i]) + } + + return b.String() +} + +// ` "count": 3,` — the shape the JSON lexer reports, 1-based columns. +func countLine() (string, []theme.Span) { + return ` "count": 3,`, []theme.Span{ + {Col: 3, Kind: theme.SyntaxKey}, + {Col: 10, Kind: theme.SyntaxPunct}, + {Col: 12, Kind: theme.SyntaxNumber}, + {Col: 13, Kind: theme.SyntaxPunct}, + } +} + +// THE invariant: colouring never changes what is written, at any width. If this +// holds under truncation then no escape was ever cut, because the visible text +// would not survive it. +func TestRenderSpans_NeverAltersTheVisibleText(t *testing.T) { + raw, spans := countLine() + + for width := 1; width <= len([]rune(raw))+5; width++ { + got := renderSpans(raw, spans, width) + assert.Equal(t, fit(raw, width), plainOf(got), + "width %d: the visible text must match the plain fit exactly", width) + } +} + +func TestRenderSpans_AppliesTheStylePerRun(t *testing.T) { + raw, spans := countLine() + + got := renderSpans(raw, spans, len([]rune(raw))) + + assert.Contains(t, got, theme.Syntax(theme.SyntaxKey).Render(`"count"`)) + assert.Contains(t, got, theme.Syntax(theme.SyntaxNumber).Render("3")) + assert.True(t, strings.HasPrefix(got, " "), + "leading indentation is emitted unstyled, not swallowed by the first run") +} + +func TestRenderSpans_Edges(t *testing.T) { + raw, spans := countLine() + + assert.Empty(t, renderSpans(raw, spans, 0), "no width, nothing to draw") + assert.Equal(t, fit(raw, 8), renderSpans(raw, nil, 8), "no spans renders the plain fit") + + // A span starting beyond the (truncated) line must not panic or invent text. + beyond := []theme.Span{{Col: 999, Kind: theme.SyntaxKey}} + assert.Equal(t, fit(raw, 6), plainOf(renderSpans(raw, beyond, 6))) +} + +// Multi-byte content must be sliced by rune, not byte, or the columns drift. +func TestRenderSpans_MultiByte(t *testing.T) { + raw := ` "café": "naïve",` + spans := []theme.Span{ + {Col: 3, Kind: theme.SyntaxKey}, + {Col: 9, Kind: theme.SyntaxPunct}, + {Col: 11, Kind: theme.SyntaxString}, + } + + for width := 1; width <= len([]rune(raw))+2; width++ { + got := renderSpans(raw, spans, width) + require.Equal(t, fit(raw, width), plainOf(got), "width %d", width) + } +} + +// An unmapped kind renders unstyled rather than wrong — SyntaxPlain is the zero +// value precisely so a token nobody classified degrades quietly. +func TestRenderSpans_PlainKindIsUnstyled(t *testing.T) { + got := renderSpans("abc", []theme.Span{{Col: 1, Kind: theme.SyntaxPlain}}, 3) + + assert.Equal(t, "abc", plainOf(got)) +} diff --git a/cmd/genspec-tui/internal/ux/panels/tree.go b/cmd/genspec-tui/internal/ux/panels/tree.go new file mode 100644 index 00000000..b92aeff9 --- /dev/null +++ b/cmd/genspec-tui/internal/ux/panels/tree.go @@ -0,0 +1,315 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package panels holds the three scrollable sub-panels of the genspec-tui +// layout: the source tree (left), the generated spec (right) and the +// diagnostics (bottom). +package panels + +import ( + "os" + "path/filepath" + "sort" + "strings" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux/key" + "github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux/theme" +) + +// node is a file or directory in the source tree. Directories list only the +// descendants that (transitively) contain Go files; everything else is pruned +// at build time. +type node struct { + name string + path string + isDir bool + expanded bool + depth int + children []*node +} + +// Tree is the left-hand source-tree explorer. It owns a cursor and a scroll +// offset (the git-janitor Base idiom) and renders a flattened view of the +// expanded nodes inside a bordered, titled box. +type Tree struct { + root *node + flat []*node // visible rows, recomputed on expand/collapse + cursor int + offset int + w, h int +} + +// NewTree builds the explorer rooted at root, pruned to Go-bearing paths. +func NewTree(root string) Tree { + t := Tree{root: buildTree(root)} + t.rebuild() + return t +} + +// SetSize fits the panel to outer dimensions w×h (border + title reserved). +func (p *Tree) SetSize(w, h int) { + p.w, p.h = w, h + p.clampOffset() +} + +// Selection returns the path of the node under the cursor, whether it is a +// directory, and false when the tree is empty. Used by the model to react to +// the user's current focus (locating diagnostics, opening a file for edit). +func (p *Tree) Selection() (path string, isDir bool, ok bool) { + n := p.current() + if n == nil { + return "", false, false + } + return n.path, n.isDir, true +} + +// Content returns the flattened tree as indented text, for clipboard copy. +func (p *Tree) Content() string { + var b strings.Builder + for i, n := range p.flat { + if i > 0 { + b.WriteString("\n") + } + b.WriteString(strings.Repeat(" ", n.depth)) + b.WriteString(n.name) + if n.isDir { + b.WriteString("/") + } + } + return b.String() +} + +// Update handles cursor movement and expand/collapse. +func (p *Tree) Update(msg tea.Msg) tea.Cmd { + km, ok := msg.(tea.KeyMsg) + if !ok { + return nil + } + + switch key.MsgBinding(km) { + case key.Up, key.K: + p.move(-1) + case key.Down, key.J: + p.move(1) + case key.PgUp: + p.move(-p.visibleRows()) + case key.PgDown: + p.move(p.visibleRows()) + case key.Home: + p.move(-len(p.flat)) + case key.End: + p.move(len(p.flat)) + case key.Right: + if n := p.current(); n != nil && n.isDir && !n.expanded { + n.expanded = true + p.rebuild() + } + case key.Left: + // Arrows only: `h`/`l` used to alias these, but `h` is now the global + // help key, advertised in the header. A pane may not shadow it — least + // of all the tree, which is where the app starts. + p.collapseOrParent() + case key.Enter: + if n := p.current(); n != nil && n.isDir { + n.expanded = !n.expanded + p.rebuild() + } + } + return nil +} + +// View renders the bordered panel; focused brightens the border/title and +// shows the cursor highlight. +func (p *Tree) View(focused bool) string { + title := theme.Title(focused).Render("source") + inner := max(p.w-2, 0) + visible := max(p.h-3, 0) + + var b strings.Builder + if len(p.flat) <= 1 && (p.root == nil || len(p.root.children) == 0) { + b.WriteString(theme.Status().Render(fit("(no .go files under root)", inner))) + } else { + end := min(p.offset+visible, len(p.flat)) + for i := p.offset; i < end; i++ { + row := p.renderRow(p.flat[i], inner) + switch { + case i == p.cursor && focused: + row = theme.Selected().Render(row) + case p.flat[i].isDir: + row = theme.Dir().Render(row) + } + b.WriteString(row) + if i < end-1 { + b.WriteString("\n") + } + } + } + + return theme.Panel(p.w, p.h, focused).Render(title + "\n" + b.String()) +} + +// ScrollBy moves the cursor by delta rows (used for mouse-wheel scrolling). +func (p *Tree) ScrollBy(delta int) { p.move(delta) } + +func (p *Tree) renderRow(n *node, width int) string { + marker := " " + if n.isDir { + if n.expanded { + marker = "▾ " + } else { + marker = "▸ " + } + } + + label := strings.Repeat(" ", n.depth) + marker + n.name + if n.isDir { + label += "/" + } + return fit(label, width) +} + +func (p *Tree) move(d int) { + if len(p.flat) == 0 { + return + } + p.cursor = clamp(p.cursor+d, 0, len(p.flat)-1) + p.clampOffset() +} + +// visibleRows is how many rows the tree body shows, for page-sized moves. +func (p *Tree) visibleRows() int { return max(p.h-3, 1) } + +// collapseOrParent collapses an expanded directory, otherwise jumps to the +// parent row. +func (p *Tree) collapseOrParent() { + n := p.current() + if n == nil { + return + } + if n.isDir && n.expanded { + n.expanded = false + p.rebuild() + return + } + for i := p.cursor - 1; i >= 0; i-- { + if p.flat[i].depth == n.depth-1 { + p.cursor = i + p.clampOffset() + return + } + } +} + +func (p *Tree) current() *node { + if p.cursor < 0 || p.cursor >= len(p.flat) { + return nil + } + return p.flat[p.cursor] +} + +// rebuild recomputes the flattened visible-row slice and re-clamps the cursor. +func (p *Tree) rebuild() { + p.flat = p.flat[:0] + if p.root != nil { + flatten(p.root, &p.flat) + } + p.cursor = clamp(p.cursor, 0, max(len(p.flat)-1, 0)) + p.clampOffset() +} + +func (p *Tree) clampOffset() { + visible := max(p.h-3, 1) + if p.cursor < p.offset { + p.offset = p.cursor + } + if p.cursor >= p.offset+visible { + p.offset = p.cursor - visible + 1 + } + if p.offset < 0 { + p.offset = 0 + } +} + +// buildTree walks root, returning its node with Go-bearing descendants +// populated. The root node is always returned (expanded) even when empty. +func buildTree(root string) *node { + rn := &node{name: filepath.Base(root), path: root, isDir: true, expanded: true} + if info, err := os.Stat(root); err == nil && info.IsDir() { + rn.children = readDir(root, 1) + } + return rn +} + +// readDir returns the directory's child nodes: subdirectories that contain Go +// files (transitively) and *.go files, directories first, each sorted by name. +func readDir(dir string, depth int) []*node { + entries, err := os.ReadDir(dir) + if err != nil { + return nil + } + + var dirs, files []*node + for _, e := range entries { + name := e.Name() + if strings.HasPrefix(name, ".") { + continue + } + + if e.IsDir() { + if name == "vendor" || name == "node_modules" { + continue + } + child := &node{name: name, path: filepath.Join(dir, name), isDir: true, depth: depth} + child.children = readDir(child.path, depth+1) + if len(child.children) > 0 { // prune dirs with no Go content + dirs = append(dirs, child) + } + continue + } + + if strings.HasSuffix(name, ".go") { + files = append(files, &node{name: name, path: filepath.Join(dir, name), depth: depth}) + } + } + + sort.Slice(dirs, func(i, j int) bool { return dirs[i].name < dirs[j].name }) + sort.Slice(files, func(i, j int) bool { return files[i].name < files[j].name }) + return append(dirs, files...) +} + +func flatten(n *node, out *[]*node) { + *out = append(*out, n) + if n.isDir && n.expanded { + for _, c := range n.children { + flatten(c, out) + } + } +} + +// fit truncates s to width with an ellipsis, or right-pads it with spaces so +// the cursor highlight spans the full inner width. +func fit(s string, width int) string { + if width <= 0 { + return "" + } + r := []rune(s) + if len(r) > width { + if width == 1 { + return "…" + } + return string(r[:width-1]) + "…" + } + return s + strings.Repeat(" ", width-len(r)) +} + +func clamp(v, lo, hi int) int { + if v < lo { + return lo + } + if v > hi { + return hi + } + return v +} diff --git a/cmd/genspec-tui/internal/ux/scan.go b/cmd/genspec-tui/internal/ux/scan.go new file mode 100644 index 00000000..0e203aa9 --- /dev/null +++ b/cmd/genspec-tui/internal/ux/scan.go @@ -0,0 +1,94 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package ux + +import ( + "encoding/json" + "time" + + tea "github.com/charmbracelet/bubbletea" + yaml "go.yaml.in/yaml/v3" + + "github.com/go-openapi/codescan" + "github.com/go-openapi/codescan/internal/parsers/grammar" + "github.com/go-openapi/codescan/internal/scanner" +) + +// scanResultMsg carries the outcome of a whole-scope scan: the spec rendered +// as both JSON and YAML, path/definition counts for the header, how long the +// scan took, every grammar.Diagnostic the build emitted (in source order), plus +// any hard error from codescan.Run. +type scanResultMsg struct { + json string + yaml string + paths int + defs int + elapsed time.Duration + diags []grammar.Diagnostic + provenance []scanner.Provenance + err error +} + +// runScan runs codescan over the whole scope (the decision-C model: one spec +// for the whole scanned set) and renders it, timing the work. It runs in a +// tea.Cmd goroutine so packages.Load latency never blocks the event loop. cfg +// is taken by value so the goroutine has a stable snapshot even if the model +// mutates its options. +func runScan(cfg codescan.Options) tea.Cmd { + return func() tea.Msg { + start := time.Now() + res := doScan(cfg) + res.elapsed = time.Since(start) + return res + } +} + +// doScan performs the scan and rendering, returning the result without timing +// (runScan stamps the elapsed time around it). +func doScan(cfg codescan.Options) scanResultMsg { + // OnDiagnostic fires synchronously inside codescan.Run, on this same + // goroutine, so a plain append is race-free. Diagnostics collected before a + // hard error are still worth surfacing, so we carry them on every return. + var diags []grammar.Diagnostic + cfg.OnDiagnostic = func(d grammar.Diagnostic) { + diags = append(diags, d) + } + // OnProvenance also fires synchronously inside codescan.Run, so a plain + // append is race-free. This is the source-side half of the cross-ref linker + // (pointer → source position); the model turns it into a SourceIndex. + var provs []scanner.Provenance + cfg.OnProvenance = func(p scanner.Provenance) { + provs = append(provs, p) + } + + sw, err := codescan.Run(&cfg) + if err != nil { + return scanResultMsg{diags: diags, provenance: provs, err: err} + } + + jb, err := json.MarshalIndent(sw, "", " ") + if err != nil { + return scanResultMsg{diags: diags, provenance: provs, err: err} + } + + res := scanResultMsg{json: string(jb), defs: len(sw.Definitions), diags: diags, provenance: provs} + if sw.Paths != nil { + res.paths = len(sw.Paths.Paths) + } + if yb, yerr := jsonToYAML(jb); yerr == nil { + res.yaml = string(yb) + } + return res +} + +// jsonToYAML reserializes ordered JSON bytes as YAML. Map keys come out +// alphabetically (yaml v3's deterministic order), which is good enough for a +// human-readable viewer. +func jsonToYAML(jb []byte) ([]byte, error) { + var v any + if err := json.Unmarshal(jb, &v); err != nil { + return nil, err + } + return yaml.Marshal(v) +} diff --git a/cmd/genspec-tui/internal/ux/theme/theme.go b/cmd/genspec-tui/internal/ux/theme/theme.go new file mode 100644 index 00000000..77e0a84d --- /dev/null +++ b/cmd/genspec-tui/internal/ux/theme/theme.go @@ -0,0 +1,200 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package theme holds the lipgloss styles shared by the model and its panels: +// a rounded-border panel box (bright when focused, dim otherwise), a panel +// title, and the status line. Kept tiny and dependency-free so both ux and +// panels can import it without a cycle. +package theme + +import "github.com/charmbracelet/lipgloss" + +// The palette. Constants rather than variables: a theme nothing can reassign at +// runtime is one less thing a rendering bug can be. +const ( + colorActive = lipgloss.Color("170") + colorInactive = lipgloss.Color("240") + colorTitle = lipgloss.Color("213") + colorDim = lipgloss.Color("245") + colorError = lipgloss.Color("203") + colorWarn = lipgloss.Color("214") + colorHint = lipgloss.Color("110") + colorFollower = lipgloss.Color("53") // muted shade of colorActive, for mirrored lines + + colorSyntaxKey = lipgloss.Color("81") + colorSyntaxString = lipgloss.Color("114") + colorSyntaxNumber = lipgloss.Color("215") + colorSyntaxKeyword = lipgloss.Color("176") + colorSyntaxPunct = lipgloss.Color("242") + colorSyntaxComment = lipgloss.Color("244") +) + +// SyntaxKind classifies a lexical run for highlighting. It is deliberately +// source-language-neutral: the JSON/YAML lexers and go/scanner both map onto it, +// so the renderer and the palette are shared rather than duplicated per pane. +type SyntaxKind uint8 + +// The syntax classes. Plain is the zero value, so an unmapped token simply +// renders unstyled rather than wrong. +// +// The Diag* classes are the exception to "lexical": they are not what a token +// IS but what the scanner said ABOUT it, overlaid on the run the diagnostic +// points at. They ride the same span mechanism because a diagnostic and a token +// address the same thing — a (line, column) run — and giving them a second +// mechanism would mean two ways to paint one line. +const ( + SyntaxPlain SyntaxKind = iota + SyntaxKey + SyntaxString + SyntaxNumber + SyntaxKeyword + SyntaxPunct + SyntaxComment + SyntaxDiagError + SyntaxDiagWarn + SyntaxDiagHint +) + +// Span is one lexical run on a rendered line, identified by where it STARTS. +// A run extends to the next span's column (or the end of the line), which is +// what lets the renderer slice raw text at known boundaries instead of +// truncating already-coloured output — the operation that corrupts escapes. +type Span struct { + Col int // 1-based column of the run's first character + Kind SyntaxKind +} + +// Syntax returns the style for a syntax class. The diagnostic classes underline +// as well as recolour: underline is the terminal's squiggle, and it survives a +// palette where the severity colour is close to a syntax one. +func Syntax(k SyntaxKind) lipgloss.Style { + switch k { + case SyntaxKey: + return lipgloss.NewStyle().Foreground(colorSyntaxKey) + case SyntaxString: + return lipgloss.NewStyle().Foreground(colorSyntaxString) + case SyntaxNumber: + return lipgloss.NewStyle().Foreground(colorSyntaxNumber) + case SyntaxKeyword: + return lipgloss.NewStyle().Foreground(colorSyntaxKeyword) + case SyntaxPunct: + return lipgloss.NewStyle().Foreground(colorSyntaxPunct) + case SyntaxComment: + return lipgloss.NewStyle().Foreground(colorSyntaxComment).Italic(true) + case SyntaxDiagError: + return lipgloss.NewStyle().Foreground(colorError).Underline(true) + case SyntaxDiagWarn: + return lipgloss.NewStyle().Foreground(colorWarn).Underline(true) + case SyntaxDiagHint: + return lipgloss.NewStyle().Foreground(colorHint).Underline(true) + case SyntaxPlain: + return lipgloss.NewStyle() + default: + return lipgloss.NewStyle() + } +} + +// Panel returns a rounded-border box style whose OUTER dimensions are w×h +// (the border consumes one cell on each side). The border is bright when +// focused and dim otherwise. +func Panel(w, h int, focused bool) lipgloss.Style { + s := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + Width(max(w-2, 0)). + Height(max(h-2, 0)) + if focused { + return s.BorderForeground(colorActive) + } + return s.BorderForeground(colorInactive) +} + +// Title styles a panel's header line. +func Title(focused bool) lipgloss.Style { + s := lipgloss.NewStyle().Bold(true) + if focused { + return s.Foreground(colorTitle) + } + return s.Foreground(colorDim) +} + +// Status styles the bottom status/help line. +func Status() lipgloss.Style { + return lipgloss.NewStyle().Foreground(colorDim) +} + +// Accent styles the app name / emphasised header text. +func Accent() lipgloss.Style { + return lipgloss.NewStyle().Foreground(colorActive).Bold(true) +} + +// Match styles a search hit in the spec pane. +func Match() lipgloss.Style { + return lipgloss.NewStyle(). + Foreground(lipgloss.Color("16")). + Background(lipgloss.Color("226")) +} + +// Modal styles a centered popup box (e.g. the scanner-options dialog). +func Modal() lipgloss.Style { + return lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(colorActive). + Padding(1, 3) +} + +// SevError, SevWarn, and SevHint style a diagnostic's severity label in the +// diagnostics pane (red / amber / blue), matching grammar.Severity order. +func SevError() lipgloss.Style { return lipgloss.NewStyle().Foreground(colorError).Bold(true) } + +// SevWarn styles a warning-severity diagnostic label. +func SevWarn() lipgloss.Style { return lipgloss.NewStyle().Foreground(colorWarn) } + +// SevHint styles a hint-severity diagnostic label. +func SevHint() lipgloss.Style { return lipgloss.NewStyle().Foreground(colorHint) } + +// Dir styles a directory row in the source tree. +func Dir() lipgloss.Style { + return lipgloss.NewStyle().Foreground(colorTitle) +} + +// Selected styles the cursor row in a navigable panel — the DRIVER line, i.e. +// the one the user is actually moving. Strong reverse-video bar (design §6.5). +func Selected() lipgloss.Style { + return lipgloss.NewStyle(). + Foreground(lipgloss.Color("231")). + Background(colorActive) +} + +// Follower styles a cross-ref line in the pane that is MIRRORING the driver. +// A muted tint of Selected, so at a glance it is obvious which pane leads and +// which one is being dragged along (design §6.5). +func Follower() lipgloss.Style { + return lipgloss.NewStyle(). + Foreground(lipgloss.Color("252")). + Background(colorFollower) +} + +// Chip styles a small standing badge in the header — currently the "h: help" +// hint, which has to survive a crowded header line without being mistaken for +// one more status field. +func Chip() lipgloss.Style { + return lipgloss.NewStyle(). + Foreground(lipgloss.Color("16")). + Background(colorHint). + Bold(true) +} + +// Gutter styles the link markers in the spec pane's and source viewer's gutter. +// Dim on purpose: they are a hint about what is navigable, not content. +func Gutter() lipgloss.Style { + return lipgloss.NewStyle().Foreground(colorHint) +} + +// Stale styles the follow-mode badge shown while the source buffer has unsaved +// edits, i.e. while cross-ref positions are older than what is on screen. +func Stale() lipgloss.Style { + return lipgloss.NewStyle(). + Foreground(lipgloss.Color("16")). + Background(colorWarn). + Bold(true) +} diff --git a/cmd/genspec-tui/internal/ux/watcher.go b/cmd/genspec-tui/internal/ux/watcher.go new file mode 100644 index 00000000..407567d3 --- /dev/null +++ b/cmd/genspec-tui/internal/ux/watcher.go @@ -0,0 +1,98 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package ux + +import ( + "os" + "path/filepath" + "strings" + + "github.com/fsnotify/fsnotify" +) + +// watcher reports Go-source changes under a directory tree as a coalesced +// stream of signals. It watches every (non-vendored, non-hidden) directory in +// the tree — fsnotify is not recursive — and re-adds directories created at +// runtime so new packages are picked up. Bursts collapse into a single pending +// signal; the model debounces further before rescanning. +type watcher struct { + fs *fsnotify.Watcher + events chan struct{} +} + +func newWatcher(root string) (*watcher, error) { + fw, err := fsnotify.NewWatcher() + if err != nil { + return nil, err + } + w := &watcher{fs: fw, events: make(chan struct{}, 1)} + w.addRecursive(root) + go w.loop() + return w, nil +} + +// Close stops watching and tears down the goroutine. +func (w *watcher) Close() error { return w.fs.Close() } + +// addRecursive adds every directory under root to the watch set, pruning the +// same noise the source tree prunes (hidden dirs, vendor, node_modules). +func (w *watcher) addRecursive(root string) { + _ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { + if err != nil || !d.IsDir() { + return nil //nolint:nilerr // skip unreadable entries, keep walking + } + if name := d.Name(); path != root && (strings.HasPrefix(name, ".") || name == "vendor" || name == "node_modules") { + return filepath.SkipDir + } + _ = w.fs.Add(path) // best effort; ignore per-dir watch-limit errors + return nil + }) +} + +func (w *watcher) loop() { + for { + select { + case ev, ok := <-w.fs.Events: + if !ok { + close(w.events) + return + } + if w.relevant(ev) { + w.signal() + } + case _, ok := <-w.fs.Errors: + if !ok { + close(w.events) + return + } + } + } +} + +// relevant reports whether an event should trigger a rescan: any *.go change, +// or a directory create/remove/rename (which can add or drop packages). Newly +// created directories are added to the watch set so their files are seen. +func (w *watcher) relevant(ev fsnotify.Event) bool { + if strings.HasSuffix(ev.Name, ".go") { + return true + } + if ev.Op&(fsnotify.Create|fsnotify.Remove|fsnotify.Rename) != 0 { + if fi, err := os.Stat(ev.Name); err == nil && fi.IsDir() { + if ev.Op&fsnotify.Create != 0 { + w.addRecursive(ev.Name) + } + return true + } + } + return false +} + +// signal posts a coalesced change notification (non-blocking: a pending signal +// already covers this change). +func (w *watcher) signal() { + select { + case w.events <- struct{}{}: + default: + } +} diff --git a/cmd/genspec-tui/main.go b/cmd/genspec-tui/main.go new file mode 100644 index 00000000..a185d19e --- /dev/null +++ b/cmd/genspec-tui/main.go @@ -0,0 +1,175 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Command genspec-tui is an interactive terminal front-end for the codescan +// Swagger-spec generator: a source-tree browser (left), the generated spec +// (right, JSON/YAML), and diagnostics (bottom). It regenerates the whole-scope +// spec on any file change. +// +// The scan is configured from two places. Boolean knobs are toggled live in the +// options overlay (`o`), which re-runs the scan on close; the value-typed ones +// — build tags, package and tag filters, naming — are command-line flags, since +// a checkbox list cannot express them. +package main + +import ( + "flag" + "fmt" + "io" + "log" + "os" + "path/filepath" + "strings" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/go-openapi/codescan" + "github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux" +) + +// cliFlags holds the raw flag values. Registration is separated from parsing so +// TestFlags_CoverEveryValueTypedOption can inspect the flag set without running +// the program. +type cliFlags struct { + set *flag.FlagSet + + workdir *string + packages *string + scanModels *bool + buildTags *string + include *string + exclude *string + includeTags *string + excludeTags *string + nameFromTags *string + nameConcatBudget *float64 +} + +// registerFlags declares every flag on fs. +// +// Each value-typed field of codescan.Options belongs here; the drift guard in +// main_test.go fails when one is added without a flag, which is how the CLI +// came to expose three options out of ten. +func registerFlags(fs *flag.FlagSet) *cliFlags { + return &cliFlags{ + set: fs, + workdir: fs.String("workdir", ".", "module directory where scanning runs (codescan WorkDir)"), + packages: fs.String("packages", "./...", "comma-separated package patterns to scan, relative to -workdir"), + scanModels: fs.Bool("scan-models", true, "also emit definitions for swagger:model types"), + buildTags: fs.String("build-tags", "", "comma-separated go build tags to apply while loading"), + include: fs.String("include", "", "comma-separated patterns; only matching packages are scanned"), + exclude: fs.String("exclude", "", "comma-separated patterns; matching packages are skipped"), + includeTags: fs.String("include-tags", "", + "comma-separated swagger tags; only matching operations are emitted"), + excludeTags: fs.String("exclude-tags", "", + "comma-separated swagger tags; matching operations are skipped"), + nameFromTags: fs.String("name-from-tags", "", + `ordered struct tags a field's name derives from (default "json"; pass empty to use the Go field name)`), + nameConcatBudget: fs.Float64("name-concat-budget", 0, + "readability cutoff for collision-renaming by concatenation (0 = codescan's default of 0.65)"), + } +} + +// options assembles the scan config. workDir is passed in already absolute. +func (c *cliFlags) options(workDir string) codescan.Options { + return codescan.Options{ + WorkDir: workDir, + Packages: splitPatterns(*c.packages), + ScanModels: *c.scanModels, + BuildTags: *c.buildTags, + Include: splitList(*c.include), + Exclude: splitList(*c.exclude), + IncludeTags: splitList(*c.includeTags), + ExcludeTags: splitList(*c.excludeTags), + NameFromTags: resolveNameFromTags(*c.nameFromTags, c.passed("name-from-tags")), + NameConcatBudget: *c.nameConcatBudget, + } +} + +// passed reports whether the named flag appeared on the command line, as +// opposed to holding its default. +func (c *cliFlags) passed(name string) bool { + seen := false + c.set.Visit(func(f *flag.Flag) { + if f.Name == name { + seen = true + } + }) + + return seen +} + +// resolveNameFromTags maps the flag onto NameFromTags's three-way contract: +// nil (unset) keeps the historic ["json"] behaviour, while an empty but NON-nil +// slice means "consult no struct tag, use the Go field name". Collapsing those +// two would make `-name-from-tags=` silently mean the opposite of what it says. +func resolveNameFromTags(raw string, passed bool) []string { + if !passed { + return nil + } + if list := splitList(raw); list != nil { + return list + } + + return []string{} +} + +func main() { + // Mute the scanner's logging. codescan writes warnings (unsupported type + // kinds, skipped builtins, …) through the standard log package, whose + // default sink is stderr — which paints over bubbletea's alt-screen and + // corrupts the TUI. Discard it globally for the lifetime of the program. + // (Reflection: codescan should accept an injected sink / route these + // through OnDiagnostic instead of the global logger — see plan.) + log.SetOutput(io.Discard) + + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, "genspec-tui:", err) + os.Exit(1) + } +} + +// run is main's body, split out so that os.Exit happens with no deferred call +// pending: exiting from inside main skipped `defer model.Close()`, leaving the +// file watcher running until the process died anyway. +func run() error { + cli := registerFlags(flag.CommandLine) + flag.Parse() + + dir, err := filepath.Abs(*cli.workdir) + if err != nil { + return err + } + + model := ux.New(cli.options(dir)) + defer model.Close() + + p := tea.NewProgram(model, tea.WithAltScreen(), tea.WithMouseCellMotion()) + _, err = p.Run() + + return err +} + +// splitList parses a comma-separated flag into trimmed, non-empty entries, +// returning nil when there is nothing usable — nil being what codescan reads as +// "no filter". +func splitList(s string) []string { + var out []string + for p := range strings.SplitSeq(s, ",") { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + + return out +} + +// splitPatterns parses the comma-separated -packages flag into non-empty, +// trimmed patterns, falling back to "./..." when nothing usable is given. +func splitPatterns(s string) []string { + if out := splitList(s); len(out) > 0 { + return out + } + + return []string{"./..."} +} diff --git a/cmd/genspec-tui/main_test.go b/cmd/genspec-tui/main_test.go new file mode 100644 index 00000000..d7179cdc --- /dev/null +++ b/cmd/genspec-tui/main_test.go @@ -0,0 +1,179 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "flag" + "io" + "reflect" + "testing" + + "github.com/go-openapi/codescan" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// optionFlags maps each value-typed codescan.Options field to the flag that +// sets it. The drift guard checks both directions: every field listed here has +// a registered flag, and every value-typed field is either listed here or +// explicitly excused below. +var optionFlags = map[string]string{ //nolint:gochecknoglobals // table for the drift guard + "WorkDir": "workdir", + "Packages": "packages", + "BuildTags": "build-tags", + "Include": "include", + "Exclude": "exclude", + "IncludeTags": "include-tags", + "ExcludeTags": "exclude-tags", + "NameFromTags": "name-from-tags", + "NameConcatBudget": "name-concat-budget", +} + +// optionsNotOnCLI are the non-bool fields deliberately without a flag. +var optionsNotOnCLI = map[string]string{ //nolint:gochecknoglobals // table for the drift guard + "InputSpec": "overlay mode: needs a spec loaded from disk, not yet exposed", + "OnDiagnostic": "wired internally to the diagnostics pane", + "OnProvenance": "wired internally to the cross-ref linker", +} + +func newTestFlags(t *testing.T) *cliFlags { + t.Helper() + fs := flag.NewFlagSet("genspec-tui", flag.ContinueOnError) + fs.SetOutput(io.Discard) + + return registerFlags(fs) +} + +// The CLI exposed three of ten options for a long time, because nothing failed +// when a new one landed. This is what fails now. +// +// Booleans are excluded: they are the options overlay's job, and +// TestOptions_OverlayCoversEveryBoolKnob guards that side. +func TestFlags_CoverEveryValueTypedOption(t *testing.T) { + cli := newTestFlags(t) + + typ := reflect.TypeFor[codescan.Options]() + for i := range typ.NumField() { + f := typ.Field(i) + if !f.IsExported() || f.Type.Kind() == reflect.Bool { + continue + } + + name, mapped := optionFlags[f.Name] + if !mapped { + if _, excused := optionsNotOnCLI[f.Name]; excused { + continue + } + + t.Errorf("codescan.Options.%s (%s) has no CLI flag. Add one and list it in "+ + "optionFlags, or excuse it in optionsNotOnCLI with a reason.", f.Name, f.Type) + + continue + } + assert.NotNil(t, cli.set.Lookup(name), + "Options.%s claims flag -%s, which is not registered", f.Name, name) + } +} + +// The tables must not rot: an entry naming a field that no longer exists, or +// one that has since become a bool, is stale. +func TestFlags_TablesAreCurrent(t *testing.T) { + typ := reflect.TypeFor[codescan.Options]() + + for name := range optionFlags { + f, ok := typ.FieldByName(name) + require.True(t, ok, "optionFlags names Options.%s, which no longer exists", name) + assert.NotEqual(t, reflect.Bool, f.Type.Kind(), + "Options.%s is a bool and belongs to the overlay, not the CLI", name) + } + for name, reason := range optionsNotOnCLI { + _, ok := typ.FieldByName(name) + assert.True(t, ok, "optionsNotOnCLI names Options.%s, which no longer exists", name) + assert.NotEmpty(t, reason, "Options.%s is excused without a reason", name) + _, alsoMapped := optionFlags[name] + assert.False(t, alsoMapped, "Options.%s is both excused and mapped to a flag", name) + } +} + +func TestFlags_Defaults(t *testing.T) { + cli := newTestFlags(t) + require.NoError(t, cli.set.Parse(nil)) + + opts := cli.options("/work") + + assert.Equal(t, "/work", opts.WorkDir) + assert.Equal(t, []string{"./..."}, opts.Packages) + assert.True(t, opts.ScanModels) + assert.Empty(t, opts.BuildTags) + assert.Nil(t, opts.Include) + assert.Nil(t, opts.ExcludeTags) + assert.Nil(t, opts.NameFromTags, "unset must stay nil so codescan applies its [\"json\"] default") + assert.Zero(t, opts.NameConcatBudget, "zero selects codescan's own 0.65 default") +} + +func TestFlags_ParseValues(t *testing.T) { + cli := newTestFlags(t) + require.NoError(t, cli.set.Parse([]string{ + "-packages", "./api/...,./models/...", + "-scan-models=false", + "-build-tags", "integration,dev", + "-include", "^github.com/me/", + "-exclude", "vendor,testdata", + "-include-tags", "public", + "-exclude-tags", "internal, deprecated", + "-name-concat-budget", "0.8", + })) + + opts := cli.options("/work") + + assert.Equal(t, []string{"./api/...", "./models/..."}, opts.Packages) + assert.False(t, opts.ScanModels) + assert.Equal(t, "integration,dev", opts.BuildTags, "build tags pass through verbatim") + assert.Equal(t, []string{"^github.com/me/"}, opts.Include) + assert.Equal(t, []string{"vendor", "testdata"}, opts.Exclude) + assert.Equal(t, []string{"public"}, opts.IncludeTags) + assert.Equal(t, []string{"internal", "deprecated"}, opts.ExcludeTags, "entries are trimmed") + assert.InDelta(t, 0.8, opts.NameConcatBudget, 1e-9) +} + +// NameFromTags is three-way, and flattening it would make `-name-from-tags=` +// mean the opposite of what it says. +func TestFlags_NameFromTagsIsThreeWay(t *testing.T) { + for _, c := range []struct { + name string + args []string + want []string + wantNil bool + }{ + {"unset keeps the historic json default", nil, nil, true}, + {"explicit empty means the Go field name", []string{"-name-from-tags="}, []string{}, false}, + {"a list is ordered as given", []string{"-name-from-tags", "form,json"}, []string{"form", "json"}, false}, + {"entries are trimmed", []string{"-name-from-tags", " form , json "}, []string{"form", "json"}, false}, + } { + t.Run(c.name, func(t *testing.T) { + cli := newTestFlags(t) + require.NoError(t, cli.set.Parse(c.args)) + + got := cli.options("/work").NameFromTags + + if c.wantNil { + assert.Nil(t, got) + + return + } + require.NotNil(t, got, "an explicitly passed flag must never yield nil") + assert.Equal(t, c.want, got) + }) + } +} + +func TestSplitHelpers(t *testing.T) { + assert.Nil(t, splitList("")) + assert.Nil(t, splitList(" , , ")) + assert.Equal(t, []string{"a", "b"}, splitList(" a , b ")) + + assert.Equal(t, []string{"./..."}, splitPatterns(""), "an empty -packages falls back") + assert.Equal(t, []string{"./..."}, splitPatterns(" , ")) + assert.Equal(t, []string{"./x"}, splitPatterns("./x")) +} diff --git a/docs/genspec-tui.png b/docs/genspec-tui.png new file mode 100644 index 0000000000000000000000000000000000000000..77082b4ed3edbbe3b23f466f58fbc9d3cef2929b GIT binary patch literal 352656 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%0DZ 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%0AM~=39`?o>v0h+S5xkAP*W65hY%{K4! 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 Date: Fri, 31 Jul 2026 15:35:47 +0200 Subject: [PATCH 2/3] build: add the genspec-tui module to the monorepo workspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings the new module into the workspace so `go test work ./...` covers it and the shared CI runs it, rather than the module carrying a bespoke workflow of its own. The `go` directive moves to 1.25.8 across every module and the workspace file. That is not a preference: the JSON and YAML lexers in go-openapi/core declare go 1.25.8, and a module's directive must be at least its dependencies'. Only cmd/genspec-tui requires those lexers, so only it is forced; the rest are aligned to it deliberately, and the workspace file must be at least the highest of them. The floor moves by a patch rather than a minor, so the supported Go minors are unchanged and the toolchain-independence job's oldstable assumption still holds — its note is updated to record which value it now depends on and why, since it named the old one. Four linters are excluded for this module by path, each with its reason: the layout arithmetic that mnd flags is code where the number is the explanation, the switches exhaustive flags read open-ended keyboard and mouse input rather than a closed domain, and unparam flags helpers that keep a general signature. The library keeps all of them. Everything else the linters found is fixed rather than excused, including the module-local ordering drift. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Frederic BIDON --- .codecov.yml | 7 ++++--- .github/workflows/toolchain-independence.yml | 6 +++++- .gitignore | 3 +++ .golangci.yml | 20 ++++++++++++++++++++ docs/examples/go.mod | 2 +- fixtures/go.mod | 2 +- go.mod | 2 +- go.work | 20 ++++++++++++++++++++ 8 files changed, 55 insertions(+), 7 deletions(-) create mode 100644 go.work diff --git a/.codecov.yml b/.codecov.yml index c8edfe2a..a5ba8e96 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -1,8 +1,9 @@ +codecov: + notify: + after_n_builds: 2 + coverage: status: patch: default: target: 80% -ignore: - - internal/scantest - - fixtures diff --git a/.github/workflows/toolchain-independence.yml b/.github/workflows/toolchain-independence.yml index 5f5a751f..67474c4d 100644 --- a/.github/workflows/toolchain-independence.yml +++ b/.github/workflows/toolchain-independence.yml @@ -78,7 +78,11 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.0 # fixtures source (FixturesDir resolves the build-time checkout path, identical across jobs) # oldstable is a DIFFERENT minor than job A's stable, so its GOROOT lives at a different path. # - # It must satisfy the fixtures module's `go` directive (currently go 1.25.0); oldstable only ever increases, so this holds going forward. + # It must satisfy the fixtures module's `go` directive (currently go 1.25.8, aligned with the + # rest of the workspace because go-openapi/core/json declares go 1.25.8). The floor moved by a + # PATCH, not a minor, so oldstable still satisfies it: oldstable resolves to the latest patch + # of the previous minor, and that is >= any released patch of it. Holds going forward, since + # oldstable only ever increases. - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version: oldstable diff --git a/.gitignore b/.gitignore index 24693b02..4528f287 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,9 @@ profile.cov # Dependency directories (remove the comment below to include it) # vendor/ +# Go workspace: commit go.work, ignore the generated checksum file +go.work.sum + # env file .env diff --git a/.golangci.yml b/.golangci.yml index 1515b84f..de017b93 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -7,6 +7,7 @@ linters: - gomoddirectives # mono-repo, multi-modules (docs/examples): local replace directives are needed for proper releasing - goconst # disabled, perhaps temporarily as this linter has become way too pick and noisy - godox + - gomoddirectives - gomodguard - gomodguard_v2 - exhaustruct @@ -54,6 +55,25 @@ linters: - third_party$ - builtin$ - examples$ + rules: + # cmd/genspec-tui is a bubbletea front-end. These four disagree with the + # shape of TUI code rather than with the code itself, and each fires often + # enough (102 between them) that //nolint would be the scattering this + # repo's linting rule tells us to avoid. Scoped by path so the library + # keeps all four. + - path: cmd/genspec-tui/ + linters: + # Layout arithmetic, where the number IS the explanation: w-2 is the + # two border columns, h-3 is border plus title row. + - mnd + # Key, mouse and message switches read open-ended INPUT, not a closed + # domain. Falling through to the code after the switch is the + # behaviour, so a required `default:` would be an empty statement + # asserting nothing. The scanner's go/types switches still get this. + - exhaustive + # clamp/clampInt keep a general (v, lo, hi) signature though every + # call site happens to pass lo=0. + - unparam formatters: enable: - gofmt diff --git a/docs/examples/go.mod b/docs/examples/go.mod index 945c4482..7e91eb8f 100644 --- a/docs/examples/go.mod +++ b/docs/examples/go.mod @@ -4,7 +4,7 @@ // codescan consumers. module github.com/go-openapi/codescan/docs/examples -go 1.25.0 +go 1.25.8 require ( github.com/go-openapi/codescan v0.0.0 diff --git a/fixtures/go.mod b/fixtures/go.mod index 99df9cd0..38ac93f7 100644 --- a/fixtures/go.mod +++ b/fixtures/go.mod @@ -1,6 +1,6 @@ module github.com/go-openapi/codescan/fixtures -go 1.25.0 +go 1.25.8 require ( github.com/go-openapi/runtime v0.29.3 diff --git a/go.mod b/go.mod index f064ae2e..91c32305 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/go-openapi/codescan -go 1.25.0 +go 1.25.8 toolchain go1.26.1 diff --git a/go.work b/go.work new file mode 100644 index 00000000..ba644503 --- /dev/null +++ b/go.work @@ -0,0 +1,20 @@ +go 1.25.8 + +// Workspace for the codescan monorepo: the main library module (.) and the +// genspec-tui front-end module (./cmd/genspec-tui), kept in separate go.mod +// files so the TUI's bubbletea dependency tree never pollutes the lean library. +// +// The workspace is what lets CI test every module in one pass (`go test work +// ./...`, via the shared go-test-monorepo workflow) — the TUI has no CI of its +// own. `go install .../cmd/genspec-tui@latest` ignores this file, so the TUI +// module's own go.mod carries the real `require` on the library. +// +// Keep the `go` directive above in step with the modules (all 1.25.8): raising +// it past them imposes a toolchain floor the code does not need, and would take +// the oldstable CI job out. +use ( + . + ./cmd/genspec-tui + ./docs/examples + ./fixtures +) From e25ad9258aed569ee2ee2b8210ae181dd32df118 Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Fri, 31 Jul 2026 15:35:48 +0200 Subject: [PATCH 3/3] fix: keep .txt golden fixtures LF on Windows checkouts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .gitattributes already states the rule — golden fixtures must keep LF endings on every platform so byte comparison does not trip on a checkout defaulting to core.autocrlf=true — but listed only *.json and two golden directories. The polymorphism tutorial's diagnostics golden is a .txt, so on Windows it was read back with CRLF and compared against a string joined with LF: expected: "...discovered as a subtype of...\r\n" actual: "...discovered as a subtype of...\n" Extending the pattern to *.txt covers it. The three .txt files tracked here are that golden, the spellcheck wordlist and a vendored README, so the wider glob costs nothing and stops the next golden of that kind from slipping through. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Frederic BIDON --- .gitattributes | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitattributes b/.gitattributes index edb53f7e..a68cd0d0 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,5 +2,6 @@ # so byte-level comparison doesn't trip on Windows checkouts that # default to core.autocrlf=true. *.json text eol=lf +*.txt text eol=lf internal/parsers/grammar/grammar_test/testdata/golden/* text eol=lf fixtures/integration/golden/* text eol=lf