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/.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 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/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/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/docs/genspec-tui.png b/docs/genspec-tui.png new file mode 100644 index 00000000..77082b4e Binary files /dev/null and b/docs/genspec-tui.png differ 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 +)