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..a90296cc --- /dev/null +++ b/.github/workflows/tui-test.yml @@ -0,0 +1,63 @@ +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 + env: + GOEXPERIMENT: jsonv2 + with: + version: latest + only-new-issues: true + working-directory: cmd/genspec-tui + - + name: Test the TUI CLI + env: + GOEXPERIMENT: jsonv2 + 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/go.mod b/cmd/genspec-tui/go.mod new file mode 100644 index 00000000..aec7574e --- /dev/null +++ b/cmd/genspec-tui/go.mod @@ -0,0 +1,52 @@ +module github.com/go-openapi/codescan/cmd/genspec-tui + +go 1.25.0 + +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/testify/v2 v2.5.1 + go.yaml.in/yaml/v3 v3.0.4 +) + +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 v0.23.1 // indirect + github.com/go-openapi/jsonreference v0.21.6 // indirect + github.com/go-openapi/spec v0.22.6 // indirect + github.com/go-openapi/swag/conv v0.26.1 // indirect + github.com/go-openapi/swag/jsonname v0.26.1 // indirect + github.com/go-openapi/swag/jsonutils v0.26.1 // indirect + github.com/go-openapi/swag/loading v0.26.1 // indirect + github.com/go-openapi/swag/mangling v0.26.1 // indirect + github.com/go-openapi/swag/stringutils v0.26.1 // indirect + github.com/go-openapi/swag/typeutils v0.26.1 // indirect + github.com/go-openapi/swag/yamlutils v0.26.1 // indirect + github.com/lucasb-eyer/go-colorful v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.22 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.24 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/mod v0.37.0 // indirect + golang.org/x/sync v0.21.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.38.0 // indirect + golang.org/x/tools v0.46.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..78daac70 --- /dev/null +++ b/cmd/genspec-tui/go.sum @@ -0,0 +1,95 @@ +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/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4= +github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY= +github.com/go-openapi/jsonreference v0.21.6 h1:NZ5nGfnaM1n4I43Xjm1e5/M2GjOwQwndQz22uhxwD+Y= +github.com/go-openapi/jsonreference v0.21.6/go.mod h1:xzbgtQ3ZbWxvET3AxdzCJlJt6vkovbf+IfSPJjD0tUY= +github.com/go-openapi/spec v0.22.6 h1:Tyy1pLaNCM8GBCFLoGYLonjJi6zykqyLCjXLc19ZPic= +github.com/go-openapi/spec v0.22.6/go.mod h1:HZvTHat+iH0PALQRWhrqIHtU/PEqxqd89fu0MxGlMeM= +github.com/go-openapi/swag/conv v0.26.1 h1:slr5FVkg9Wc3Y5zcwenD8Sd/PQ94b2I/QJI7N7KTBpg= +github.com/go-openapi/swag/conv v0.26.1/go.mod h1:mvQXgPptZk9GTrFgGwWvT4q+dN+zQej9JfmGwnipz1A= +github.com/go-openapi/swag/jsonname v0.26.1 h1:VReupaV6WxlAsCn0e4DUfgV6bPmINnPpyJDLqSfNPcE= +github.com/go-openapi/swag/jsonname v0.26.1/go.mod h1:OvdW6BoWoj33pTfi7x9vFrgmT+fk7aw0BRwvCE0YOuc= +github.com/go-openapi/swag/jsonutils v0.26.1 h1:2hdBfFkHg+7Wrz2VsCbeyR6hzkRDs7AztnMR2u84yOY= +github.com/go-openapi/swag/jsonutils v0.26.1/go.mod h1:U+RMJH3wa+6BRiphuRtIyI8fW9HPFqFQ4sHk2oRx0UQ= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.1 h1:1CD7NiLLb/TXl3tOnFYU4b+mNfb5rtgHkaA+q7RMYYQ= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.1/go.mod h1:ZWafc8nMdYzTE3uYY6W86f0n46+IF0g4uUyRhJw/kXc= +github.com/go-openapi/swag/loading v0.26.1 h1:E9K4wqXeROlhjFQ13K9zMz6ojFGXIggGe+ad1odrK9w= +github.com/go-openapi/swag/loading v0.26.1/go.mod h1:3qvRIlWzWdq1HvmldwmuJ2ohpcAryN6xVt2OTKd0/7E= +github.com/go-openapi/swag/mangling v0.26.1 h1:gpYI4WuPKFJJVjV5cDLGlDVJhFIxYjQc7yN5eEb4CqM= +github.com/go-openapi/swag/mangling v0.26.1/go.mod h1:POETDH01hqAdASXfw7ISEd9bCOE6xBHOt8NHmGZRmYM= +github.com/go-openapi/swag/stringutils v0.26.1 h1:f88uYyTso7TnHrKM/bUBsQ5e2wKf37cpgo6pvbzd9yU= +github.com/go-openapi/swag/stringutils v0.26.1/go.mod h1:Sc6d3bU8fgk5AyZR8/8jEQ+Is/Ald+TD/IIggPN8UJk= +github.com/go-openapi/swag/typeutils v0.26.1 h1:yg42FgMzRR6PVQ3M3qHz1s+Y6/P4HoJ3cBarXa3OVnU= +github.com/go-openapi/swag/typeutils v0.26.1/go.mod h1:VfnV+oUtSP2vCSCn2aJgnr8OevUYemyIzzS1VOzS10o= +github.com/go-openapi/swag/yamlutils v0.26.1 h1:0TSLK+lXs9vfIhAWzBeI/lOzEnIoot6WTCO1aAeWFTk= +github.com/go-openapi/swag/yamlutils v0.26.1/go.mod h1:7W5b7PRX9MxwL7TjeG7H8HkyBGRsIDRObhyMWFgBI2M= +github.com/go-openapi/testify/enable/yaml/v2 v2.5.1 h1:q9NtHwK4qHF7yZziBPvZyv7zWAIk8ok88Gh2mR6Jpc8= +github.com/go-openapi/testify/enable/yaml/v2 v2.5.1/go.mod h1:JW0MXIotCYps/XsgJnG3a8Q7rE5xAiBwoOD5OfaIQBk= +github.com/go-openapi/testify/v2 v2.5.1 h1:TMdhCaw8fUNraVSf3Omoob1dO/AzBfhtFAPW0an6sBo= +github.com/go-openapi/testify/v2 v2.5.1/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= +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.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= +github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +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.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU= +github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +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.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +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.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.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.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/tools v0.46.0 h1:7jTurBkPZu4moS/Uy4OQT1M+QBlsj3wejyZwsT8Z7rk= +golang.org/x/tools v0.46.0/go.mod h1:FrD85F8l+NWL+9XWBSyVSHO6Ne4jutsfIFba7AWQ5Ys= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 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/key/bindings.go b/cmd/genspec-tui/internal/ux/key/bindings.go new file mode 100644 index 00000000..666ffe22 --- /dev/null +++ b/cmd/genspec-tui/internal/ux/key/bindings.go @@ -0,0 +1,50 @@ +// 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" + Space Binding = " " + Esc Binding = "esc" + Enter Binding = "enter" +) + +// 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..0ee86af9 --- /dev/null +++ b/cmd/genspec-tui/internal/ux/model.go @@ -0,0 +1,1126 @@ +// 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/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 +) + +// 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 *SpecIndex // rendered-line ↔ JSON-pointer map for the active format + srcIndex *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 + + 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}, + {"DescWithRef", "keep a field description beside its $ref (allOf wrap)", &m.cfg.DescWithRef}, + } + 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 = 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 open file pane: the read-only viewer navigates; the editor captures + // input (except a few app keys). + if m.leftMode == modeView && m.focused == paneTree { + if m.fileView.Editing() { + return m.handleEditKey(msg) + } + return m.handleViewerKey(msg) + } + + // Diagnostics pane: select a diagnostic and follow it to source. Unhandled + // keys fall through to the global bindings below. + if m.focused == paneDiag { + if cmd, handled := m.handleDiagNav(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.spec.SetFormat("JSON") + m.refreshSpec() + return m, nil + case key.CtrlY: + m.spec.SetFormat("YAML") + m.refreshSpec() + return m, nil + case key.R: + return m, m.startScan() + case key.O: + m.exitFollow() + 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.spec.ClearSearch() + m.spec.ClearHighlight() + 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 +} + +// 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 "(no source)" + } + if m.currentFile != d.Pos.Filename { + m.loadFileQuietly(d.Pos.Filename) + } + m.fileView.GotoLine(d.Pos.Line - 1) // follower scrolls; 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.Model, tea.Cmd) { + switch key.MsgBinding(msg) { + case key.Up, key.K: + m.fileView.NavUp() + return m, nil + case key.Down, key.J: + m.fileView.NavDown() + return m, nil + 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 m, nil + case key.I, key.Enter: + return m, m.fileView.StartEdit() + case key.Esc: + if m.follow != followOff { + m.exitFollow() + return m, nil + } + m.leftMode = modeBrowse + return m, nil + 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.C: + return m, m.copyFocused() + case key.CtrlQ, key.CtrlC: + return m, tea.Quit + } + return m, nil +} + +// 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. + if desc, ok := m.linkSourceToSpec(); ok { + m.fileView.Blur() + m.focused = paneSpec + m.notice = "→ " + desc + } else { + m.notice = "no spec node anchored at or above this line" + } + 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.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.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: + return m.spec.Update(msg) + 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 + } + if body == "" { + m.specIndex = nil + m.spec.SetContent("(no spec generated yet)") + return + } + if yamlFmt { + m.specIndex = BuildYAMLIndex([]byte(body)) + } else { + m.specIndex = BuildJSONIndex([]byte(body)) + } + m.spec.SetContent(body) +} + +// 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.HighlightLine(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.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 = "" + m.spec.ClearHighlight() +} + +// 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 + } + if desc, ok := m.linkSourceToSpec(); ok { + m.followTarget = desc + } else { + m.followTarget = "(no spec node)" + } + 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.TopLine()) + if !ok { + return "(no node)" + } + if specLine, found := m.specIndex.LineForPointer(ptr); found { + m.spec.MarkLine(specLine) // mark the mapped node, no scroll + } + pos, ok := m.srcIndex.PositionFor(ptr) + if !ok { + return ptr + " (no source)" + } + if m.currentFile != pos.Filename { + m.loadFileQuietly(pos.Filename) + } + m.fileView.GotoLine(pos.Line - 1) // follower scrolls; 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. Returns a status description and +// whether the node was found in the current spec render. +func (m *Model) linkSourceToSpec() (string, bool) { + if m.currentFile == "" { + return "", 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 { + return "", false + } + specLine, ok := m.specIndex.LineForPointer(ptr) + if !ok { + return ptr + " (not in view)", false + } + m.spec.HighlightLine(specLine) // follower scrolls + highlights + 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 { + b.WriteString(fmt.Sprintf("%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.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.TopLine()); ok { + return theme.Status().Render("node " + ptr + " · f: follow mode · /: search · tab: focus · c: copy") + } + } + 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)" + } + return theme.Accent().Render(" "+label+" ") + + theme.Status().Render(" "+target+" · esc / f: exit follow") +} + +// 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..06b7f6ae --- /dev/null +++ b/cmd/genspec-tui/internal/ux/model_diag_test.go @@ -0,0 +1,105 @@ +// 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, "(no source)", m.followTarget) +} + +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_follow_test.go b/cmd/genspec-tui/internal/ux/model_follow_test.go new file mode 100644 index 00000000..db5c75c3 --- /dev/null +++ b/cmd/genspec-tui/internal/ux/model_follow_test.go @@ -0,0 +1,104 @@ +// 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/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 = 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 = 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. + m.fileView.GotoLine(0) // source line 1, before the first anchor (line 5) + m.syncFollowIfActive() + assert.Equal(t, "(no spec node)", 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 = newSpecIndex( + map[int]string{0: "/definitions/User"}, + map[string]int{"/definitions/User": 0}, + ) + m.srcIndex = 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 = 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/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..6e68608d --- /dev/null +++ b/cmd/genspec-tui/internal/ux/panels/fileview.go @@ -0,0 +1,224 @@ +// 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/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 +} + +// 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, scrolling it into +// view. Used by cross-ref navigation to land on a node's source line. The editor +// cursor is synced lazily by StartEdit, so this stays cheap when called on every +// follow-mode scroll. +func (p *FileView) GotoLine(line int) { p.gotoNav(line) } + +// 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(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). +func (p *FileView) viewerBody(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)) + textW := max(inner-(numW+1), 0) + + var b strings.Builder + end := min(p.offset+visible, total) + for i := p.offset; i < end; i++ { + row := fmt.Sprintf("%*d ", numW, i+1) + fit(lines[i], textW) + if i == p.navLine && navActive { + row = theme.Selected().Render(row) + } + b.WriteString(row) + if i < end-1 { + b.WriteString("\n") + } + } + return b.String() +} + +// 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/spec.go b/cmd/genspec-tui/internal/ux/panels/spec.go new file mode 100644 index 00000000..95c0fd43 --- /dev/null +++ b/cmd/genspec-tui/internal/ux/panels/spec.go @@ -0,0 +1,213 @@ +// 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/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 + + xrefLine int // content line highlighted by cross-ref follow, -1 for none +} + +// 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, xrefLine: -1} +} + +// 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. A new +// spec invalidates any cross-ref highlight (the line may have moved). +func (p *Spec) SetContent(s string) { + p.content = s + p.xrefLine = -1 + p.render() +} + +// 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, scrolls to the first +// match, and returns the match count. A search supersedes any cross-ref +// highlight. +func (p *Spec) Search(query string) int { + p.query = query + p.matchIdx = 0 + p.xrefLine = -1 + 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) +} + +// TopLine returns the 0-based index of the top visible content line, for +// mapping the current scroll position to a spec node via the SpecIndex. +func (p *Spec) TopLine() int { return p.vp.YOffset } + +// scrollContext is how many lines of context to keep above a scrolled-to target. +const scrollContext = 2 + +// MarkLine marks the 0-based content line as the cross-ref target (same style +// the source pane uses for its nav line) WITHOUT scrolling. Used when the spec +// is the driver pane (the user controls its scroll) and only the node mapping +// should be shown. +func (p *Spec) MarkLine(line int) { + if p.xrefLine == line { + return // already marked; avoid a re-render on every driver scroll + } + p.xrefLine = line + p.render() +} + +// HighlightLine marks the line and scrolls it into view with a little context +// above. Used when the spec is the follower (source→spec navigation). +func (p *Spec) HighlightLine(line int) { + p.vp.SetYOffset(max(line-scrollContext, 0)) + p.MarkLine(line) +} + +// ClearHighlight drops the cross-ref highlight, if any. +func (p *Spec) ClearHighlight() { + if p.xrefLine == -1 { + return + } + p.xrefLine = -1 + p.render() +} + +// 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. +func (p *Spec) View(focused bool) string { + title := theme.Title(focused).Render("spec · " + p.format) + return theme.Panel(p.w, p.h, focused).Render(title + "\n" + p.vp.View()) +} + +// render rebuilds the viewport content from the raw text, applying the active +// search highlight (per-substring) and the cross-ref highlight (whole line). +// The cross-ref line takes the whole-line style; search matches are still +// counted on it so n/N stays consistent. +func (p *Spec) render() { + if p.query == "" && p.xrefLine == -1 { + p.matches = nil + p.vp.SetContent(p.content) + return + } + + 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.xrefLine: + lines[i] = theme.Selected().Render(ln) + case isMatch: + lines[i] = highlightAll(ln, p.query) + } + } + p.vp.SetContent(strings.Join(lines, "\n")) +} + +func (p *Spec) scrollToMatch() { + if len(p.matches) == 0 { + return + } + // keep a little context above the match + p.vp.SetYOffset(max(p.matches[p.matchIdx]-2, 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..929ecf12 --- /dev/null +++ b/cmd/genspec-tui/internal/ux/panels/spec_test.go @@ -0,0 +1,51 @@ +// 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 newLoadedSpec() Spec { + sp := NewSpec() + sp.SetSize(40, 12) + sp.SetContent("{\n \"a\": 1,\n \"b\": 2,\n \"c\": 3\n}") + return sp +} + +func TestSpec_HighlightLine(t *testing.T) { + sp := newLoadedSpec() + assert.Equal(t, -1, sp.xrefLine, "no highlight on a fresh spec") + + sp.HighlightLine(2) + assert.Equal(t, 2, sp.xrefLine, "cross-ref highlight set") + assert.Contains(t, sp.Content(), "\"b\": 2", "raw content is unchanged (highlight is view-only)") + + sp.ClearHighlight() + assert.Equal(t, -1, sp.xrefLine, "highlight cleared") +} + +func TestSpec_HighlightInvalidatedBySearchAndContent(t *testing.T) { + sp := newLoadedSpec() + + sp.HighlightLine(1) + sp.Search("b") + assert.Equal(t, -1, sp.xrefLine, "a search supersedes the cross-ref highlight") + + sp.HighlightLine(3) + sp.SetContent("{\n \"x\": 9\n}") + assert.Equal(t, -1, sp.xrefLine, "new content invalidates the highlight") +} + +func TestSpec_RenderPreservesContent(t *testing.T) { + sp := newLoadedSpec() + sp.HighlightLine(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/sourceindex.go b/cmd/genspec-tui/internal/ux/sourceindex.go new file mode 100644 index 00000000..c28edfdf --- /dev/null +++ b/cmd/genspec-tui/internal/ux/sourceindex.go @@ -0,0 +1,114 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package ux + +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 +} + +// 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/sourceindex_test.go b/cmd/genspec-tui/internal/ux/sourceindex_test.go new file mode 100644 index 00000000..73906e1f --- /dev/null +++ b/cmd/genspec-tui/internal/ux/sourceindex_test.go @@ -0,0 +1,130 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package ux + +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/specindex.go b/cmd/genspec-tui/internal/ux/specindex.go new file mode 100644 index 00000000..8d1d2b16 --- /dev/null +++ b/cmd/genspec-tui/internal/ux/specindex.go @@ -0,0 +1,193 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package ux + +import ( + "bytes" + "encoding/json/jsontext" + "sort" + "strconv" + "strings" + + yaml "go.yaml.in/yaml/v3" +) + +// 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 +} + +// 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) +} + +// 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 a SpecIndex from indented JSON bytes using the +// jsontext.Decoder token stream: after each token, StackPointer() names the +// current node, and InputOffset() locates it. The first occurrence of a pointer +// is the member's declaration line; later repeats (its value, its close) are +// ignored. Best-effort — on a decode error it returns what it has so far (the +// rendered bytes are always valid JSON, so this is just defensive). +// +// Requires GOEXPERIMENT=jsonv2 (see plan §4 / build §A0). +func BuildJSONIndex(b []byte) *SpecIndex { + lt := newLineTable(b) + line2ptr := make(map[int]string) + ptr2line := make(map[string]int) + + d := jsontext.NewDecoder(bytes.NewReader(b)) + for { + // Any error (io.EOF at end, or a malformed-input error that can't occur + // on our own freshly-rendered bytes) ends the scan — best-effort. + if _, err := d.ReadToken(); err != nil { + break + } + p := string(d.StackPointer()) + if p == "" { + continue + } + if _, seen := ptr2line[p]; seen { + continue + } + line := lt.lineAt(d.InputOffset()) + ptr2line[p] = line + line2ptr[line] = p + } + return newSpecIndex(line2ptr, ptr2line) +} + +// BuildYAMLIndex builds a SpecIndex from YAML bytes by walking the yaml.Node +// tree, which carries a 1-based .Line per node. Pointers use the same RFC 6901 +// escaping as the JSON side, so an index built from either render is +// interchangeable. Best-effort — a parse error yields an empty index. +func BuildYAMLIndex(b []byte) *SpecIndex { + var root yaml.Node + if err := yaml.Unmarshal(b, &root); err != nil { + return newSpecIndex(map[int]string{}, map[string]int{}) + } + line2ptr := make(map[int]string) + ptr2line := make(map[string]int) + // A document node wraps a single content node. + for _, doc := range docContents(&root) { + walkYAML(doc, "", line2ptr, ptr2line) + } + return newSpecIndex(line2ptr, ptr2line) +} + +func docContents(n *yaml.Node) []*yaml.Node { + if n.Kind == yaml.DocumentNode { + return n.Content + } + return []*yaml.Node{n} +} + +// walkYAML records a pointer→line for each mapping member and sequence element, +// recursing into nested mappings/sequences. Scalars are leaves (their line is +// recorded by the parent member/element). +func walkYAML(n *yaml.Node, prefix string, line2ptr map[int]string, ptr2line map[string]int) { + switch n.Kind { + case yaml.MappingNode: + for i := 0; i+1 < len(n.Content); i += 2 { + key, val := n.Content[i], n.Content[i+1] + ptr := prefix + "/" + escapePointer(key.Value) + record(key.Line-1, ptr, line2ptr, ptr2line) + walkYAML(val, ptr, line2ptr, ptr2line) + } + case yaml.SequenceNode: + for i, val := range n.Content { + ptr := prefix + "/" + strconv.Itoa(i) + record(val.Line-1, ptr, line2ptr, ptr2line) + walkYAML(val, ptr, line2ptr, ptr2line) + } + default: + // Scalars and aliases are leaves; their line was recorded by the + // enclosing member/element. DocumentNode is unwrapped in docContents. + } +} + +func record(line int, ptr string, line2ptr map[int]string, ptr2line map[string]int) { + if _, seen := ptr2line[ptr]; seen { + return + } + ptr2line[ptr] = line + if _, taken := line2ptr[line]; !taken { + line2ptr[line] = ptr + } +} + +// escapePointer applies RFC 6901 token escaping (~ → ~0, / → ~1), matching what +// jsontext.Pointer produces on the JSON side. +func escapePointer(tok string) string { + tok = strings.ReplaceAll(tok, "~", "~0") + tok = strings.ReplaceAll(tok, "/", "~1") + return tok +} + +// itoa is a tiny strconv.Itoa avoiding the import churn for one call site. +// lineTable maps byte offsets to 0-based line numbers via the positions of '\n'. +type lineTable struct { + nlOffsets []int64 // sorted offsets of each '\n' +} + +func newLineTable(b []byte) lineTable { + var nl []int64 + for i, c := range b { + if c == '\n' { + nl = append(nl, int64(i)) + } + } + return lineTable{nlOffsets: nl} +} + +// lineAt returns the 0-based line containing the byte at off (= number of +// newlines strictly before off). +func (lt lineTable) lineAt(off int64) int { + return sort.Search(len(lt.nlOffsets), func(i int) bool { return lt.nlOffsets[i] >= off }) +} diff --git a/cmd/genspec-tui/internal/ux/specindex_test.go b/cmd/genspec-tui/internal/ux/specindex_test.go new file mode 100644 index 00000000..156a0560 --- /dev/null +++ b/cmd/genspec-tui/internal/ux/specindex_test.go @@ -0,0 +1,148 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package ux + +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") + } +} + +func TestLineTable(t *testing.T) { + lt := newLineTable([]byte("a\nbb\n\nc")) + cases := []struct { + off int64 + line int + }{ + {0, 0}, // 'a' + {1, 0}, // '\n' after a still line 0 + {2, 1}, // 'b' + {5, 2}, // '\n' (the empty line's newline) → line 2 + {6, 3}, // 'c' + } + for _, c := range cases { + if got := lt.lineAt(c.off); got != c.line { + t.Errorf("lineAt(%d) = %d, want %d", c.off, got, c.line) + } + } +} 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..ec9ae8b3 --- /dev/null +++ b/cmd/genspec-tui/internal/ux/theme/theme.go @@ -0,0 +1,90 @@ +// 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") +) + +// 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. +func Selected() lipgloss.Style { + return lipgloss.NewStyle(). + Foreground(lipgloss.Color("231")). + Background(colorActive) +} 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/go.work b/go.work new file mode 100644 index 00000000..00629d1c --- /dev/null +++ b/go.work @@ -0,0 +1,12 @@ +go 1.25.0 + +// 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 ( + . + ./docs/examples +)