diff --git a/.codecov.yml b/.codecov.yml index c8edfe2a..a5ba8e96 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -1,8 +1,9 @@ +codecov: + notify: + after_n_builds: 2 + coverage: status: patch: default: target: 80% -ignore: - - internal/scantest - - fixtures diff --git a/.github/workflows/tui-test.yml b/.github/workflows/tui-test.yml new file mode 100644 index 00000000..f7b7e676 --- /dev/null +++ b/.github/workflows/tui-test.yml @@ -0,0 +1,59 @@ +name: tui-test + +permissions: + pull-requests: read + contents: read + +on: + push: + branches: + - master + + pull_request: + +jobs: + tui-test: + runs-on: ubuntu-latest + + strategy: + matrix: + go-version: [stable] + + steps: + - + name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - + name: Setup Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version: ${{ matrix.go-version }} + - + name: golangci-lint + uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee # v9.2.1 + with: + version: latest + only-new-issues: true + working-directory: cmd/genspec-tui + - + name: Test the TUI CLI + run: > + cd cmd/genspec-tui + go test -race -count=1 + -coverprofile='genspec-tui.coverage.ubuntu-latest-${{ matrix.go-version }}.out' + -covermode=atomic + -coverpkg='github.com/go-openapi/codescan' + ./... + - + name: Upload coverage artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + path: 'genspec-tui.coverage.ubuntu-latest-${{ matrix.go-version }}.out' + name: 'genspec-tui.coverage.ubuntu-latest-${{ matrix.go-version }}' + retention-days: 1 + + collect-coverage: + needs: [tui-test] + if: ${{ !cancelled() && needs.tui-test.result == 'success' }} + uses: go-openapi/ci-workflows/.github/workflows/collect-coverage.yml@f00f5763ddb0c59105de5f565da8cac323fce2bf # v0.3.5 + secrets: inherit 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..344ac75f 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 diff --git a/cmd/genspec-tui/README.md b/cmd/genspec-tui/README.md new file mode 100644 index 00000000..9ea9bd0e --- /dev/null +++ b/cmd/genspec-tui/README.md @@ -0,0 +1,232 @@ + + +# 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 | + +Scanner options that are booleans 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. + +## 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. + +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 | +|-----|--------| +| `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 | +| `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 | +| `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 | +| `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. + +### 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. +- **`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 +golangci-lint run --new-from-rev master +``` + +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..5d12b86c --- /dev/null +++ b/cmd/genspec-tui/internal/ux/diagnostics_render.go @@ -0,0 +1,118 @@ +// 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) (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 + row = theme.Selected().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..75b3da92 --- /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); 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) + 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) + + for _, want := range []string{ + "2 diagnostics (1 error, 1 warning)", + "models/a.go:12:3", // path trimmed to workdir + "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, "/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..a3a982ab --- /dev/null +++ b/cmd/genspec-tui/internal/ux/gadgets/clipboard.go @@ -0,0 +1,86 @@ +// 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" +) + +// 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 errors.New("no clipboard tool available (tried xclip, xsel, wl-copy)") +} + +// 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/index/refindex.go b/cmd/genspec-tui/internal/ux/index/refindex.go new file mode 100644 index 00000000..e68e4e32 --- /dev/null +++ b/cmd/genspec-tui/internal/ux/index/refindex.go @@ -0,0 +1,216 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package index + +import ( + "net/url" + "sort" + "strings" +) + +// 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 +} + +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), + } +} + +// 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() (*SpecIndex, *RefIndex) { + 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 NewSpecIndex(a.line2ptr, a.ptr2line), + &RefIndex{byTarget: a.byTarget, byLine: a.byLine, total: a.total} +} 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..b8bf00b2 --- /dev/null +++ b/cmd/genspec-tui/internal/ux/index/refindex_test.go @@ -0,0 +1,189 @@ +// 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)) + + 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)) + + 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)) + + 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) { + spec, refs := BuildJSONIndex([]byte(refSpecJSON)) + + 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)) + + 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":{}}`)) + 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..a8e46ef9 --- /dev/null +++ b/cmd/genspec-tui/internal/ux/index/specindex.go @@ -0,0 +1,100 @@ +// 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} +} + +// BuildJSONIndex builds the per-render indexes from indented JSON bytes: the +// line↔pointer SpecIndex and the $ref-site RefIndex, 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) (*SpecIndex, *RefIndex) { + acc := newIndexAccum() + + lex := lexer.NewVerbatimWithBytes(b, lexer.WithJSONPointer(true)) + for tok := range lex.Tokens() { + acc.add(lex.JSONPointer().String(), lex.Line()-1, tok.IsKey(), tok.Value()) + } + + 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) (*SpecIndex, *RefIndex) { + acc := newIndexAccum() + + lex := yamllexer.NewWithBytes(b, yamllexer.WithJSONPointer(true)) + for tok := range lex.Tokens() { + acc.add(lex.JSONPointer().String(), lex.Line()-1, tok.IsKey(), tok.Value()) + } + + 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..433067dc --- /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)) + + // 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)) + + 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(`{}`)) + 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..fd9634cd --- /dev/null +++ b/cmd/genspec-tui/internal/ux/key/bindings.go @@ -0,0 +1,69 @@ +// 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 = " " + 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/model.go b/cmd/genspec-tui/internal/ux/model.go new file mode 100644 index 00000000..e595cce5 --- /dev/null +++ b/cmd/genspec-tui/internal/ux/model.go @@ -0,0 +1,1435 @@ +// 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 +) + +// 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" +) + +// 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). +type optToggle struct { + label string + desc string + ptr *bool +} + +// 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 + + 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 + + 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. workdir/packages/scanModels map directly onto +// codescan.Options; the source tree browses workdir. 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(workdir string, packages []string, scanModels bool) *Model { + sp := spinner.New() + sp.Spinner = spinner.Dot + + si := textinput.New() + si.Prompt = "/" + si.Placeholder = "search spec" + + m := &Model{ + cfg: codescan.Options{WorkDir: workdir, Packages: packages, ScanModels: scanModels}, + focused: paneTree, + spin: sp, + searchInput: si, + tree: panels.NewTree(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). + m.optToggles = []optToggle{ + {"ScanModels", "also emit definitions for swagger:model types", &m.cfg.ScanModels}, + {"SkipExtensions", "omit x-go-* vendor extensions from the spec", &m.cfg.SkipExtensions}, + {"SetXNullableForPointers", "mark pointer fields as x-nullable: true", &m.cfg.SetXNullableForPointers}, + {"RefAliases", "emit $ref for type aliases instead of expanding them", &m.cfg.RefAliases}, + {"TransparentAliases", "treat aliases as transparent (never define them)", &m.cfg.TransparentAliases}, + {"EmitRefSiblings", "keep a field description beside its $ref (allOf wrap)", &m.cfg.EmitRefSiblings}, + } + if w, err := newWatcher(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...) +} + +// 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) +} + +// 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) +} + +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.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.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) { + switch key.MsgBinding(msg) { + case key.Up, key.K: + m.moveDiagCursor(-1) + return nil, true + case key.Down, key.J: + m.moveDiagCursor(+1) + return nil, true + case key.F: + // Toggle diagnostics-driven follow mode: as the selection moves, the + // source pane mirrors the selected diagnostic's position. + m.toggleFollow(followDiag) + return nil, true + } + return nil, false +} + +// 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.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() + 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 + if content, err := os.ReadFile(path); err != nil { + m.fileView.SetFile(filepath.Base(path), "error reading file: "+err.Error()) + } else { + m.fileView.SetFile(relTo(m.cfg.WorkDir, path), string(content)) + } + m.fileView.SetAnchors(m.srcIndex.AnchorLines(path)) + m.leftMode = modeView +} + +// 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) +} + +// 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 +} + +// 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) +} + +// 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) { + switch key.MsgBinding(msg) { + case key.Up, key.K: + if m.optCursor > 0 { + m.optCursor-- + } + case key.Down, key.J: + if m.optCursor < len(m.optToggles)-1 { + m.optCursor++ + } + 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: + return m.diag.Update(msg) + } + 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() +} + +// 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.diag.SetContent(content) + if line >= 0 { + m.diag.ScrollToLine(line) + } +} + +// 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 +} + +// 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.SetContent("(no spec generated yet)") + m.rebuildGutters() + return + } + if yamlFmt { + m.specIndex, m.refIndex = index.BuildYAMLIndex([]byte(body)) + } else { + m.specIndex, m.refIndex = index.BuildJSONIndex([]byte(body)) + } + 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)} + } +} + +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 "" +} + +// 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} }) +} + +// 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) +} + +// 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()) + } + + 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() +} + +// optionsView renders the scanner-options modal: a bordered list of boolean +// toggles with checkboxes and a cursor caret. +func (m *Model) optionsView() string { + labelW := 0 + for _, t := range m.optToggles { + labelW = max(labelW, len(t.label)) + } + + var b strings.Builder + b.WriteString(theme.Accent().Render("Scanner options")) + b.WriteString("\n\n") + + for i, t := range m.optToggles { + caret := " " + if i == m.optCursor { + caret = "▸ " + } + box := "[ ]" + if *t.ptr { + box = "[x]" + } + label := fmt.Sprintf("%-*s", labelW, t.label) + if i == m.optCursor { + // highlight the whole row, description included + b.WriteString(theme.Selected().Render(fmt.Sprintf("%s%s %s %s", caret, box, label, t.desc))) + } else { + fmt.Fprintf(&b, "%s%s %s ", caret, box, label) + b.WriteString(theme.Status().Render(t.desc)) + } + b.WriteString("\n") + } + + b.WriteString("\n") + b.WriteString(theme.Status().Render("space: toggle · esc/o: apply & close")) + + return theme.Modal().Render(b.String()) +} + +// headerLine shows the app name, the (shortened) workdir, the active format, +// spec stats, and a scan spinner / ready indicator. +func (m *Model) headerLine() string { + wd := shortenPath(m.cfg.WorkDir, max(m.width-44, 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) + } + + left := theme.Accent().Render("genspec-tui") + 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..b3dca7aa --- /dev/null +++ b/cmd/genspec-tui/internal/ux/model_diag_test.go @@ -0,0 +1,106 @@ +// 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) + assert.Equal(t, 2, line) +} 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..94feccdb --- /dev/null +++ b/cmd/genspec-tui/internal/ux/model_e2e_test.go @@ -0,0 +1,238 @@ +// 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.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_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_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/panels/diagnostics.go b/cmd/genspec-tui/internal/ux/panels/diagnostics.go new file mode 100644 index 00000000..e5976401 --- /dev/null +++ b/cmd/genspec-tui/internal/ux/panels/diagnostics.go @@ -0,0 +1,62 @@ +// 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 } + +// ScrollToLine scrolls so the 0-based content line sits near the top (with a +// little context above). Used to keep the selected diagnostic in view. +func (p *Diagnostics) ScrollToLine(line int) { p.vp.SetYOffset(max(line-scrollContext, 0)) } + +// 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..758df279 --- /dev/null +++ b/cmd/genspec-tui/internal/ux/panels/fileview.go @@ -0,0 +1,276 @@ +// 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 +} + +// 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 } + +// 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} +} + +// 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 = content + 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)) +} + +// 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++ { + row := p.gutterFor(i+1, gutW) + fmt.Sprintf("%*d ", numW, i+1) + fit(lines[i], textW) + if i == p.navLine && navActive { + row = style.Render(row) + } + 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_test.go b/cmd/genspec-tui/internal/ux/panels/fileview_test.go new file mode 100644 index 00000000..f51bf324 --- /dev/null +++ b/cmd/genspec-tui/internal/ux/panels/fileview_test.go @@ -0,0 +1,78 @@ +// 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)") +} + +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..57508c73 --- /dev/null +++ b/cmd/genspec-tui/internal/ux/panels/spec.go @@ -0,0 +1,312 @@ +// 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 +} + +// 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)) +} + +// 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 } + +func clampSpec(v, lo, hi int) int { + if v < lo { + return lo + } + if v > hi { + return hi + } + return v +} + +// 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()) +} + +// 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() +} + +// 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() +} + +// 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) + } + switch { + case i == p.cursor: + ln = p.cursorStyle().Render(ln) + case isMatch: + ln = highlightAll(ln, p.query) + } + 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/tree.go b/cmd/genspec-tui/internal/ux/panels/tree.go new file mode 100644 index 00000000..4506be7b --- /dev/null +++ b/cmd/genspec-tui/internal/ux/panels/tree.go @@ -0,0 +1,301 @@ +// 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.Right, key.L: + if n := p.current(); n != nil && n.isDir && !n.expanded { + n.expanded = true + p.rebuild() + } + case key.Left, key.H: + 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()) +} + +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() +} + +// ScrollBy moves the cursor by delta rows (used for mouse-wheel scrolling). +func (p *Tree) ScrollBy(delta int) { p.move(delta) } + +// 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..611574ba --- /dev/null +++ b/cmd/genspec-tui/internal/ux/theme/theme.go @@ -0,0 +1,116 @@ +// 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" + +var ( + 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 +) + +// 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) +} + +// 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..750ff5ad --- /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 +} + +// 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: + } +} + +// Close stops watching and tears down the goroutine. +func (w *watcher) Close() error { return w.fs.Close() } diff --git a/cmd/genspec-tui/main.go b/cmd/genspec-tui/main.go new file mode 100644 index 00000000..67f96b48 --- /dev/null +++ b/cmd/genspec-tui/main.go @@ -0,0 +1,70 @@ +// 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. +// +// This is the scaffold: an empty three-panel UX shell. Scanner wiring, +// file-watching, and diagnostics rendering land in later increments. +package main + +import ( + "flag" + "fmt" + "io" + "log" + "os" + "path/filepath" + "strings" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/go-openapi/codescan/cmd/genspec-tui/internal/ux" +) + +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) + + workdir := flag.String("workdir", ".", "module directory where scanning runs (codescan WorkDir)") + packages := flag.String("packages", "./...", "comma-separated package patterns to scan, relative to -workdir") + scanModels := flag.Bool("scan-models", true, "also emit definitions for swagger:model types") + flag.Parse() + + dir, err := filepath.Abs(*workdir) + if err != nil { + fmt.Fprintln(os.Stderr, "genspec-tui:", err) + os.Exit(1) + } + + model := ux.New(dir, splitPatterns(*packages), *scanModels) + defer model.Close() + + p := tea.NewProgram(model, tea.WithAltScreen(), tea.WithMouseCellMotion()) + if _, err := p.Run(); err != nil { + fmt.Fprintln(os.Stderr, "genspec-tui:", err) + os.Exit(1) + } +} + +// 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 { + var out []string + for _, p := range strings.Split(s, ",") { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + if len(out) == 0 { + return []string{"./..."} + } + return out +} diff --git a/docs/examples/go.mod b/docs/examples/go.mod index 945c4482..7e91eb8f 100644 --- a/docs/examples/go.mod +++ b/docs/examples/go.mod @@ -4,7 +4,7 @@ // codescan consumers. module github.com/go-openapi/codescan/docs/examples -go 1.25.0 +go 1.25.8 require ( github.com/go-openapi/codescan v0.0.0 diff --git a/fixtures/go.mod b/fixtures/go.mod index 99df9cd0..38ac93f7 100644 --- a/fixtures/go.mod +++ b/fixtures/go.mod @@ -1,6 +1,6 @@ module github.com/go-openapi/codescan/fixtures -go 1.25.0 +go 1.25.8 require ( github.com/go-openapi/runtime v0.29.3 diff --git a/go.mod b/go.mod index f9f5c84e..fd8ff9e2 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..999734d3 --- /dev/null +++ b/go.work @@ -0,0 +1,14 @@ +go 1.26 + +// 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. +// +// Dev-only: `go install .../cmd/genspec-tui@latest` ignores this file, so the +// TUI module's own go.mod carries the real `require` on the library once wired. +use ( + . + ./cmd/genspec-tui + ./docs/examples + ./fixtures +)