diff --git a/README.md b/README.md index 76cd89ef1..5551b3ceb 100644 --- a/README.md +++ b/README.md @@ -5,46 +5,78 @@ possible. PR & Suggestions are welcome. The plugin covers most features required for a gopher. -- Perproject setup. Allows you setup plugin behavior per project based on project files(launch.json, .gonvim) -- Async jobs with libuv -- Syntax highlight & Texobject: Native treesitter support is faster and more accurate. All you need is a theme support - treesitter, try [aurora](https://github.com/ray-x/aurora), [starry.nvim](https://github.com/ray-x/starry.nvim). Also, - there are quite a few listed in [awesome-neovim](https://github.com/rockerBOO/awesome-neovim) -- All the GoToXxx (E.g reference, implementation, definition, goto doc, peek code/doc etc) You need lspconfig setup. - There are lots of posts on how to set it up. You can also check my [navigator](https://github.com/ray-x/navigator.lua) - gopls setup [lspconfig.lua](https://github.com/ray-x/navigator.lua/blob/master/lua/navigator/lspclient/clients.lua) -- Show interface implementation with virtual_text -- gopls commands: e.g. fillstruct, organize imports, list modules, list packages, gc_details, generate, change - signature, etc. -- Runtime lint/vet/compile: Supported by LSP (once you set up your LSP client), GoLint with golangci-lint(v2) also - supported -- Build/Make/Test: Go.nvim provides support for these by an async job wrapper. -- Test coverage: run test coverage and show coverage sign and function metrics -- Dlv Debug: with [nvim-dap](https://github.com/mfussenegger/nvim-dap) and - [Dap UI](https://github.com/rcarriga/nvim-dap-ui). Go adapter included, zero config for your debug setup. -- Load vscode launch configuration -- Unit test: generate unit test framework with [gotests](https://github.com/cweill/gotests). Run test with - ginkgo/gotestsum/go test -- Add and remove tag for struct with tag modify(gomodifytags) -- Code format: Supports LSP format and GoFmt(with golines) -- CodeLens : gopls codelens and codelens action support -- Comments: Add autodocument for your package/function/struct/interface. This feature is unique and can help you - suppress golint errors... -- Go to alternative go file (between test and source) -- Test with ginkgo, gotestsum inside floaterm (to enable floaterm, guihua.lua has to be installed) -- Code refactor made easy: GoFixPlural, FixStruct, FixSwitch, Add comment, IfErr, ModTidy, GoGet, extract function/block - with codeactions... Most of the tools are built on top of treesitter AST or go AST. Fast and accurate. -- GoCheat get go cheatsheet from [cheat.sh](https://cheat.sh/). -- Smart build tag detection when debug/run tests (e.g. `//go:build integration`) -- Generate mocks with mockgen -- Inlay hints: gopls (version 0.9.x or greater) inlay hints; version 0.10.x inlay hints are enabled by default. -- luasnip: go.nvim included a feature rich luasnips you definitally need to try. -- Treesitter highlight injection: go.nvim included a treesitter highlight injection for SQL and JSON. -- Treesitter also injects highlight for `go template`, `gohtmltmpl` -- Generate return value for current function -- Generate go file with template -- Generate go struct from json -- MockGen support +**LSP & Navigation** + +- gopls commands: fillstruct, organize imports, list modules/packages, gc_details, generate, change signature, etc. +- All GoToXxx (reference, implementation, definition, doc, peek code/doc, etc.) via gopls/lspconfig. + Check [navigator.lua](https://github.com/ray-x/navigator.lua) for a floating UI experience +- Show interface implementations with virtual text +- Inlay hints (gopls 0.9+; enabled by default in 0.10+) +- CodeLens & CodeAction support + +**Build, Test & Coverage** + +- Async build/make/test with libuv job wrapper +- Test with `go test`, [gotestsum](https://github.com/gotestyourself/gotestsum), or [ginkgo](https://github.com/onsi/ginkgo) — including floaterm support +- Generate unit tests with [gotests](https://github.com/cweill/gotests) (table-driven, testify) +- Test coverage: run coverage, display signs, and show function metrics +- Smart build-tag detection for debug/test runs (e.g. `//go:build integration`) + +**Code Generation & Refactoring** + +- `GoIfErr`, `GoFillStruct`, `GoFillSwitch`, `GoFixPlurals`, `GoGenReturn` — powered by treesitter/go AST +- `GoImpl` — generate interface method stubs +- `GoEnum` — generate enum helpers +- `GoJson2Struct` — convert JSON/YAML to Go structs +- `GoMockGen` — generate mocks with mockgen +- `GoNew` — create files/projects from templates (including `gonew`) +- Struct tag management with [gomodifytags](https://github.com/fatih/gomodifytags) + +**Formatting & Linting** + +- Format via LSP (gopls) or CLI (`gofumpt`, `goimports`, `golines`) +- Lint with golangci-lint (v2) — LSP diagnostics or async background checks + +**Debugging** + +- Dlv debug with [nvim-dap](https://github.com/mfussenegger/nvim-dap) and [nvim-dap-ui](https://github.com/rcarriga/nvim-dap-ui) — zero-config Go adapter included +- Load VSCode `launch.json` configurations + +**AI-Powered** + +- `GoAI` — natural-language command dispatcher (translates natural-language into go.nvim commands via Copilot/OpenAI). Use `-f` to include the full command catalog in the prompt for better accuracy +- `GoCmtAI` — generate doc comments with AI for the declaration at cursor +- `GoDocAI` — AI-powered documentation: find symbols by vague name and generate rich docs from source +- `GoCodeReview` — AI code review for files, selections, or diffs; results populate the quickfix list +- `GoAIChat` — ask questions about Go code with AI; auto-includes function context and LSP references + +**Documentation & Navigation** + +- `GoDoc` / `GoDocBrowser` — view docs in a float or browser +- `GoCheat` — cheat sheets from [cheat.sh](https://cheat.sh/) +- `GoAlt` / `GoAltV` / `GoAltS` — switch between test and implementation files +- `GoPkgOutline` / `GoPkgSymbols` — package-level symbol outlines + +**Comments & Docs** + +- Auto-generate doc comments for packages, functions, structs, and interfaces (suppresses golint warnings) + +**Module & Workspace** + +- `GoModTidy`, `GoModVendor`, `GoGet`, `GoWork`, etc. +- `Gomvp` — rename/move packages +- `GoVulnCheck` — run govulncheck for vulnerability scanning + +**Syntax & Snippets** + +- Treesitter-based syntax highlighting and textobjects +- Treesitter highlight injection for SQL, JSON, `go template`, and `gohtmltmpl` +- Feature-rich LuaSnip snippets included + +**Project & Configuration** + +- Per-project setup via `.gonvim/init.lua` or `launch.json` +- Async jobs with libuv throughout ## Installation @@ -391,6 +423,20 @@ If no argument provided, fallback to lsp.hover() Similar to GoDoc, but open the browser with the doc link. If no argument provided, open doc for current function/package +## GoDocAI + +AI-powered documentation lookup. When you can't remember the exact package or function name, `GoDocAI` finds the +symbol using `go doc` and gopls workspace/symbol, then generates comprehensive documentation via AI. + +```vim +:GoDocAI Println +:GoDocAI http.ListenAndServe +:GoDocAI json Marshal +``` + +If no argument is given, uses the word under the cursor. Results are shown in a floating window. +Requires `ai = { enable = true }` in your go.nvim setup. + ## GoPkgOutline A symbol outline for all symbols (var, const, func, struct, interface etc) inside a package You can still use navigator @@ -540,9 +586,10 @@ The code will be: type GoLintComplaining struct{} ``` -| command | Description | -| ------- | ----------- | -| GoCmt | Add comment | +| command | Description | +| ------- | -------------------------------------------------------------------------------------------------------------- | +| GoCmt | Add comment | +| GoCmtAI | Generate doc comment using AI (Copilot or OpenAI) for declaration at cursor. Requires `ai = { enable = true }` | ## GoMod Commands @@ -725,6 +772,65 @@ if err != nil { | labels | | | outline | | +### AI Code Review + +`GoCodeReview` uses an LLM (Copilot or OpenAI-compatible) to review Go code and populate the quickfix list with +actionable findings (errors, warnings, suggestions). + +| Command | Description | +| ----------------------- | ------------------------------------------------------------------- | +| GoCodeReview | Review the entire current file | +| :'<,'>GoCodeReview | Review the visual selection only | +| GoCodeReview -d | Review only changes (diff) against the default branch (main/master) | +| GoCodeReview -d develop | Review only changes (diff) against a specific branch | +| GoCodeReview -b | Review with a brief/compact prompt (saves tokens) | +| GoCodeReview -d -b | Diff review with brief prompt | +| GoCodeReview -m {text} | Provide change description for context-aware review | +| GoCodeReview -m | Open interactive editor for multi-line change description | + +The `-m` flag lets you describe what the changes are about so the reviewer can give more targeted feedback: + +```vim +:GoCodeReview -m add lru cache to search, remove fifo cache +:GoCodeReview -d -m refactor error handling for retries +:GoCodeReview -m " opens a floating editor for multi-line input +``` + +Literal `\n` in the message text is converted to newlines. When `-m` is used without text, a +floating editor (guihua.textview) opens for multi-line input — submit with ``, cancel with `q`. + +Requires `ai = { enable = true }` in your go.nvim setup. Results are loaded into the quickfix list. + +### AI Chat + +`GoAIChat` lets you ask questions about Go code with AI. It automatically includes code context: + +- **Visual selection**: selected code is sent as context +- **Cursor in function**: the enclosing function text and LSP references/callers are included +- **No context**: opens an interactive prompt + +| Command | Description | +| -------------------------------- | ------------------------------------------------ | +| :'<,'>GoAIChat explain this code | Explain visually selected code | +| GoAIChat check for bugs | Check enclosing function for bugs | +| GoAIChat refactor this code | Suggest refactoring for the function under cursor | +| GoAIChat | Open interactive prompt | +| GoAIChat create a commit summary | Summarize git diff as a commit message | + +Tab completion provides common prompts: `explain this code`, `refactor this code`, +`check for bugs`, `check concurrency safety`, `suggest improvements`, etc. + +### AI Documentation + +`GoDocAI` finds a Go symbol by vague/partial name and generates rich AI documentation from its source. + +| Command | Description | +| --------------- | -------------------------------------------------------------- | +| GoDocAI {query} | Find symbol and generate AI documentation in a floating window | +| GoDocAI | Use word under cursor as query | + +Requires `ai = { enable = true }` in your go.nvim setup. + ### Debug Commands | Command | Description | @@ -892,6 +998,14 @@ require('go').setup({ -- can also set to a list of colors to define colors to choose from -- e.g {'#D8DEE9', '#5E81AC', '#88C0D0', '#EBCB8B', '#A3BE8C', '#B48EAD'} }, + ai = { + enable = false, -- set to true to enable AI features (GoAI, GoCmtAI) + provider = 'copilot', -- 'copilot' or 'openai' (any OpenAI-compatible endpoint) + model = nil, -- model name, default: 'gpt-4o' for copilot, 'gpt-4o-mini' for openai + api_key_env = 'OPENAI_API_KEY', -- env var name that holds the API key, env only! DO NOT put your key here. + base_url = nil, -- for openai-compatible APIs, e.g.: 'https://api.openai.com/v1' + confirm = true, -- confirm before executing the translated command + }, trouble = false, -- true: use trouble to open quickfix test_efm = false, -- errorfomat for quickfix, default mix mode, set to true will be efm only luasnip = false, -- enable included luasnip snippets. you can also disable while add lua/snips folder to luasnip load diff --git a/doc/go.txt b/doc/go.txt index 58748b47a..ef31b2003 100644 --- a/doc/go.txt +++ b/doc/go.txt @@ -120,42 +120,119 @@ go ~ *go-nvim-compiler-gotest* gotest~ - with `go test`, `richgo`, or `ginkgo` + with `go test`, `richgo`, `gotestsum`, or `ginkgo` *go-nvim-compiler-golint* golint~ - with golangcl-lint + with golangci-lint ============================================================================== COMMANDS *go-nvim-commands* +INSTALL & UPDATE ~ + :GoInstallBinaries *:GoInstallBinaries* Make sure all dependent tools are downloaded and installed. -:GoUpdateBinaries *:GoUpdataBinaries* +:GoUpdateBinaries *:GoUpdateBinaries* Make sure all tools are updated. +:GoInstallBinary {tool_name} *:GoInstallBinary* + Install a specific tool binary. -:GoInstallBinary {tool_name} *:GoInstallBinary* - Make sure all dependent tools are downloaded and installed. +:GoUpdateBinary {tool_name} *:GoUpdateBinary* + Update a specific tool binary. + +BUILD, RUN & LINT ~ + +:GoMake *:GoMake* + Async make for the current project. + +:GoBuild {-tags=tagname} {package_name} *:GoBuild* + Build the package in the current directory. (|:pwd|) + %: build current file + %:h: build current package + +:GoRun {args} *:GoRun* + Equal to "go run" with args. You can also specify -F to run in + floaterm. + +:GoGenerate {args} *:GoGenerate* + Run `go generate`. Args: package path. + +:GoStop {job} *:GoStop* + Stop a running async job started with GoRun or other async commands. + +:GoVet {args} *:GoVet* + Run `go vet`. Args: package path. + +:GoLint {args} *:GoLint* + Run golangci-lint. Configured via |golangci_lint| option. + +:GoTool {subcommand} *:GoTool* + Run `go tool `. Tab completion for available tools. + +TEST ~ + +:GoTest {-cnfptvFba} {-t tagname} {package_name} *:GoTest* + Run tests with various options: + -c: compile test in current package + -n: test nearest function at cursor + -v: verbose mode + -f: test current file + -p: test current package + -t: build tag + -F: run in floaterm + -b {build_args}: e.g. `-b -gcflags="all -N -l"` + -a {args}: extra args for test, check `go help testflag` + -parallel {N}: run tests in parallel + package_name: test specific package + +:GoTestFunc {args} {-t tagname} *:GoTestFunc* + Test the function under cursor. If cursor is not on a test function, + shows a selector to pick one. + -s: select the function you want to run + -v: verbose mode + +:GoTestSubCase *:GoTestSubCase* + Test the table-driven test sub-case under cursor. + +:GoTestFile {-t tagname} {-v} *:GoTestFile* + Test all functions in the current file. + -v: verbose mode + +:GoTestPkg {args} *:GoTestPkg* + Test all tests in the current package. + +:GoTestSum {packagename} {-w} {gotestsum_args} *:GoTestSum* + Run tests with gotestsum. + -w: watch file changes and re-run test + packagename: test package + gotestsum_args: gotestsum arguments, + e.g. GoTestSum -- -tags=integration ./io/http + +:GoAddTest *:GoAddTest* + Generate unit test for the function under cursor (using gotests). -:GoUpdateBinary {tool_name} *:GoUpdataBinary* - Make sure tool_name are up to date. +:GoAddExpTest {args} *:GoAddExpTest* + Generate tests for all exported functions in the current file. +:GoAddAllTest {args} *:GoAddAllTest* + Generate tests for all functions in the current file. -:GoCoverage [flags] *:GoCoverage* +COVERAGE ~ + +:GoCoverage [flags] *:GoCoverage* Run `go test -cover` and highlight lines reported as covered and uncovered. - [flags] are passed to the `go test` command; there are two special - flags: + [flags] are passed to the `go test` command; special flags: -t coveragefile load coverage data from coveragefile - -p Coverage for current package only -r Remove existing highlighting in current buffer. -R Remove all existing highlighting. - -t Toggle display of highlighting. + -f Toggle display of highlighting. -m show coverage statistics in qf. *hl-goCoverageCovered* *hl-goCoverageUncover* @@ -163,273 +240,505 @@ COMMANDS *go-nvim-commands* Override the goCoverageCovered and/or goCoverageUncover highlight groups if the defaults don't work well for you. augroup my-go-nvim-coverage - au! au Syntax go hi goCoverageCovered guibg=green au Syntax go hi goCoverageUncover guibg=brown augroup end -:GoImports {package_name} *:GoImports* - Add, modify imports. +FORMAT & IMPORTS ~ -:GoBuild {-tags=tagname}{package_name} *:GoBuild* - Build the package in the current directory. (|:pwd|) - -g: build with debug info - %: build current file - %:h: build current package +:GoFmt {formatter} *:GoFmt* + Format code. Optional arg: gofmt, goimports, gofumpt, golines. + Default configured via |gofmt| option. -:GoRun {args} *:GoRun* - equal to "go run " with args. You can also specify -F to run in floaterm +:GoImports {package_name} *:GoImports* + Add, modify imports. Tab completion for package names. + Uses gopls by default (configurable via |goimports| option). -:GoStop *:GoStop* - stop the task started with GoRun -:GoTest {-cnfpt} {-t tagname} {package_name} {-a args} *:GoTest* - -c: compile test in current package - -n: test nearest - -v: test verbose mode - -f: test current file - -p: test current package - -t: compile tag - -a: extra args for test, check `go help testflag` concatenate - with '\ ' if there are any spaces or multiple args - -b {build_args}: e.g. `-b -gcflags="all-N\ -l"` - -n {count}: disable cache -count={count} - package_name: test package - -:GoTestSum {packagename} {-w} {gotestsum_args} *:GoTestSum* - -w: watch file changes and re-run test - packagename: test package - gottestsum_args: gotestsum arguments, - e.g. GoTestSum -- -tags=integration ./io/http +CODE GENERATION ~ +:GoIfErr *:GoIfErr* + Generate `if err != nil` boilerplate at cursor. -:GoTestFile {-t tagname}{-v} *:GoTestFile* - Test current file - -v: verbose mode +:GoFillStruct *:GoFillStruct* + Fill struct literal with default field values. -:GoTestFunc {args} {-t tagname} *:GoTestFunc* - Test current function - {args} -s: select the function you want to run - -v: verbose mode +:GoFillSwitch *:GoFillSwitch* + Fill switch statement with all type/enum cases. -:GoAddTest *:GoAddTest* - Add unit test for current function +:GoFixPlurals *:GoFixPlurals* + Merge consecutive same-type function parameters. -:GoFmt {-a} *:GoFmt* - Format code with golines+gofumpt. -a: apply to all files -:GoVet *:GoVet* - Run go vet -:GoCheat query *:GoCheat* - Run `curl cheat.sh/go/query` -:GoGet {package_url} *:GoGet* - Run go get {package_url}, if package_url not provided, will parse - current line and use it as url if valid +:GoCmt *:GoCmt* + Generate doc comment for function/struct/interface under cursor. -:GoLint *:GoLint* - Run golangci-lint +:GoCmtAI *:GoCmtAI* + Generate doc comment using AI/Copilot for the declaration at cursor. + Uses the configured AI provider (see |ai| option). +:GoImpl {receiver} {interface} *:GoImpl* + Generate interface method stubs. + If cursor is on a struct name, receiver can be omitted. + If cursor is on an interface name, interface can be omitted. + Examples: > + :GoImpl io.Reader + :GoImpl f *File io.Reader + :GoImpl f io.Reader + :GoImpl MyType + :GoImpl mt MyType +< + Tab completion for interface names. -:GoRename *:GoRename* - Rename the identifier under the cursor. +:GoEnum {args} *:GoEnum* + Generate enum helpers for the type under cursor. -:Gomvp *:Gomvp* - Rename the module name under the cursor. +:GoGenReturn *:GoGenReturn* + Generate return values for the function call under cursor. -:{range}GoAddTag [flags] *:GoAddTags* - Add, modify, or remove struct tags. Will only apply to the fields in - {range} if it's given, or applied to all fields in the struct if it's - omitted. +:["x]GoJson2Struct {name} *:GoJson2Struct* + Convert JSON (visual selection or register) to Go struct. + Supports visual range, bang to put result to register, + "x to get JSON from register x. - All tags in [flags] will be added. A tag can be followed by a `,` - (comma) and an option to add the option, or set to a specific name - with `tag:name`. +STRUCT TAGS ~ - Tags can be removed by using `-rm tag`; options can be removed by - using `-rm tag,opt` +:{range}GoAddTag [tags] *:GoAddTag* + Add struct tags. Will only apply to the fields in {range} if given, + or applied to all fields in the struct if omitted. - The value of |g:go-nvim_tag_default| is used if no [flags] is given. + Tags can include options: `json,omitempty`. Multiple tags separated + by spaces. - options: - -transform (-t) - -add-options (-a) {opts} Examples: > - :GoTag json Add tag "json" - :GoTag json,omitempty Add tag with omitempty, or add - omitempty for fields where it - already exists. - :GoTag json,omitempty db Add two tags - :GoAddTags sometag:foo Set the tag sometag to the - string foo. - :GoTags json -rm yaml Combine add and rm + :GoAddTag json Add tag "json" + :GoAddTag json,omitempty Add with omitempty + :GoAddTag json,omitempty db Add two tags < -:{range}GoRmTag [flags] *:GoRmTags* - Remove struct tags. Will apply to the fields in - {range} if it's given, or applied to all fields in the struct if it's - omitted. - +:{range}GoRmTag [tags] *:GoRmTag* + Remove struct tags. Examples: > - :GoRmTag json Remove a tag - :GoRmTag json,omitempty Remove the omitempty option - :GoRmTag json -rm db Remove two tags + :GoRmTag json Remove json tag + :GoRmTag json,omitempty Remove omitempty option :GoRmTag Remove all tags +< + +:GoClearTag *:GoClearTag* + Remove all struct tags from the struct under cursor. + +:{range}GoModifyTag [tags] *:GoModifyTag* + Modify struct tag options (e.g. transform to snakecase/camelcase). + Tab completion for options. + +MODULE MANAGEMENT ~ + +:GoModTidy {args} *:GoModTidy* + Run `go mod tidy`. Without args, uses gopls; with args, runs + `go mod tidy` directly. -:{range}GoClearTag *:GoClearTags* - Remove all tags +:GoModVendor {args} *:GoModVendor* + Run `go mod vendor`. -:GoDebug {options} *:GoDebug* - Start debugger - options: -t(test), -R(restart), -n(nearest), -f(file), -s(stop), -b(breakpoint) - -h(help), -c(compile), -a (attach remote) - If no option provided, will - 1) check launch.json and launch the valid configuration from - launch.json, fallback to GoDebug file - 2) With -t option, if current file is not test file, will switch to test file - and run test for current function - 3) If cursor inside scope of a test function, will debug current test function, - if cursor inside a test file, will debug current test file +:GoModDnld {args} *:GoModDnld* + Run `go mod download`. -:GoDbgConfig *:GoDbgConfig* - Open launch.json +:GoModGraph {args} *:GoModGraph* + Show module dependency graph. -:GoDbgKeys *:GoDbgKeys* - Display keymaps for debugger +:GoModWhy {module} *:GoModWhy* + Show why a module is needed. If no arg, uses module under cursor. -:GoDbgStop *:GoDbgStop* - Stop debug session and unmap all keymaps, same as GoDebug -s +:GoModInit {name} *:GoModInit* + Initialize new Go module. -:GoDbgContinue *:GoDbgContinue* - Continue debug session, keymap `c` +:GoGet {package_url} *:GoGet* + Run `go get {package_url}`. If not provided, will parse current line + and use it as URL if valid. -:GoCreateLaunch *:GoCreateLaunch* - Create a sample launch.json configuration file +:GoWork {cmd} {path} *:GoWork* + Go workspace commands. Subcommands: run, use. -:GoBreakToggle *:GoBreakToggle* - Debugger breakpoint toggle +NAVIGATION & DOCS ~ -:GoBreakSave *:GoBreakSave* - Debugger breakpoint save to project file +:GoAlt *:GoAlt* + Open alternative file (test <-> implementation). + Supports bang (!) to create if not exists. Also GoAltS/GoAltV. -:GoBreakLoad *:GoBreakLoad* - Debugger breakpoint load from project file +:GoDoc {symbol} *:GoDoc* + Show documentation. e.g. `GoDoc fmt.Println` -:GoEnv {envfile} {load} *:GoEnv* - Load envfile and set environment variable +:GoDocBrowser {symbol} *:GoDocBrowser* + Open doc for current function/type/package in browser. + e.g. `GoDocBrowser fmt.Println` -:GoAlt *:GoAlt* - Open alternative file (test/go), Also GoAltS/GoAltV +:GoImplements *:GoImplements* + Show interface implementations via `vim.lsp.buf.implementation()`. -:GoDoc {options} *:GoDoc* - e.g. GoDoc fmt.Println +:GoPkgOutline {options} *:GoPkgOutline* + Show symbols inside a package in side panel/loclist. + Options: -f (floating win), package_name + Default: sidepanel, current package. -:GoDocBrowser {options} *:GoDocBrowser* - Open doc current function/type/package in browser - e.g. GoDocBrowser fmt.Println +:GoPkgSymbols *:GoPkgSymbols* + Show symbols inside current package in side panel/loclist. -:GoMockGen {options} *:GoMockGen* - Generate mock with go mock - options: - -s source mode(default) - -i interface mode, provide interface name or put cursor on interface - -p package name default: mocks +:GoListImports *:GoListImports* + List imports in the current file in a floating window. + +:GoCheat {query} *:GoCheat* + Run `curl cheat.sh/go/{query}`. + +CODE ACTIONS & REFACTORING ~ + +:{range}GoCodeAction *:GoCodeAction* + Run LSP code actions. Supports visual range. + +:GoCodeLenAct *:GoCodeLenAct* + Run code lens action at cursor. + +:GoRename *:GoRename* + Rename the identifier under the cursor via LSP. + +:GoGCDetails {args} *:GoGCDetails* + Toggle GC optimization details overlay for the current file. + +:GoToggleInlay *:GoToggleInlay* + Toggle LSP inlay hints for the current buffer. + +MOCK ~ + +:GoMockGen {options} *:GoMockGen* + Generate mock with gomock/mockgen. + Options: + -s source mode (default) + -i interface mode, provide interface name or put cursor on + interface + -p package name, default: mocks -d destination directory, default: ./mocks -:GoPkgOutline {options} *:GoPkgOutline* - show symbols inside a specific package in side panel/loclist - options: -f (floating win), -p package_name - default options: sidepanel, current package in vim buffer +DEBUGGING ~ + *go-debug* +Debugging requires nvim-dap. Enable with `dap_debug = true` (default). + +:GoDebug {options} *:GoDebug* + Start debugger. + Options: -t (test), -R (restart), -n (nearest), -f (file), + -s (stop), -b (breakpoint), -h (help), -c (compile), -a (attach) + If no option provided, will: + 1) Check launch.json and launch the valid configuration, + fallback to GoDebug file + 2) With -t option, if current file is not test file, will switch + to test file and run test for current function + 3) If cursor inside scope of a test function, will debug current + test function + +:GoDbgConfig *:GoDbgConfig* + Open launch.json. + +:GoCreateLaunch *:GoCreateLaunch* + Create a sample launch.json configuration file. + +:GoDbgKeys *:GoDbgKeys* + Display keymaps for debugger. + +:GoDbgStop *:GoDbgStop* + Stop debug session and unmap all keymaps. Same as `GoDebug -s`. + +:GoDbgContinue *:GoDbgContinue* + Continue debug session (keymap `c`). + +:GoBreakToggle *:GoBreakToggle* + Toggle breakpoint at cursor line. + +:GoBreakSave *:GoBreakSave* + Save breakpoints to project file. + +:GoBreakLoad *:GoBreakLoad* + Load breakpoints from project file. + +:BreakCondition *:BreakCondition* + Set a conditional breakpoint (prompts for condition). + +:LogPoint *:LogPoint* + Set a log point breakpoint (prompts for log message). + +:DapStop *:DapStop* + Stop the current DAP session. + +:DapRerun *:DapRerun* + Disconnect, close, and rerun the last DAP session. + +:DapUiFloat *:DapUiFloat* + Open dap-ui floating element. + +:DapUiToggle *:DapUiToggle* + Toggle dap-ui. + +:ReplRun *:ReplRun* + Run last command in DAP REPL. + +:ReplToggle *:ReplToggle* + Toggle DAP REPL. -:GoPkgSymbols *:GoPkgSymbols* - show symbols inside current package in side panel/loclist - options: none +:ReplOpen *:ReplOpen* + Open DAP REPL in a split. -:GoImplements {options} *:GoImplements* - GoImplements calls vim.lsp.buf.implementation +GINKGO ~ -:GoImpl {options} *:GoImpl* - e.g. GoImpl {receiver} {interface}, will check if cursor is a valid - receiver, if you park cursor on struct name, receiver can be omitted. - if you park cursor on interface name, {interface} can be omitted. - e.g ":GoImpl io.Reader", or "GoImpl f *File io.Reader" or "GoImpl - f io.Reader", or "GoImpl MyType", "GoImpl mt MyType" - you can use tab to complete the interface name. +:Ginkgo {cmd} *:Ginkgo* + Ginkgo test framework commands. + Subcommands: generate, bootstrap, build, labels, run, watch. -:GoToggleInlay - Toggle inlay hints for current buffer +:GinkgoFunc {args} *:GinkgoFunc* + Run Ginkgo test for the function under cursor. -:GoTermClose - Closes the floating term. +:GinkgoFile {args} *:GinkgoFile* + Run Ginkgo tests for the current file. -:["x]GoJson2Struct - Convert json (visual select) to go struct. - bang: put result to register - \"x : get json from register x +OTHER ~ -:GoGenReturn - generate return values for current function +:GoEnv {envfile} *:GoEnv* + Load environment variables from file. -:GoVulnCheck - run govulncheck on current project +:GoProject *:GoProject* + Setup project configuration. Loads .gonvim/init.lua if present. -:GoEnum - run goenum on current file +:GoVulnCheck {args} *:GoVulnCheck* + Run govulncheck for vulnerability scanning on current project. -:GoNew {filename} - create a new go file from template e.g. GoNew ./pkg/file.go +:GoNew {template} *:GoNew* + Create a new Go file from template. + e.g. `GoNew ./pkg/file.go` + Tab completion for available templates. + +:Gomvp {args} *:Gomvp* + Rename/move packages. Tab completion for package names. + +:GoTermClose *:GoTermClose* + Close the floating terminal (when run_in_floaterm is enabled). + +GOPLS LSP COMMANDS ~ + +:GoGopls {subcommand} {json_args} *:GoGopls* + Execute any gopls LSP command directly. + Tab completion for available subcommands. + Arguments can be JSON or key=value pairs. + Examples: > + :GoGopls tidy + :GoGopls add_import {"ImportPath":"fmt"} + :GoGopls list_known_packages + :GoGopls gc_details +< + + Available subcommands include: + add_dependency, add_import, add_test, apply_fix, assembly, + change_signature, check_upgrades, diagnose_files, doc, + edit_go_directive, extract_to_new_file, free_symbols, + gc_details, generate, go_get_package, list_imports, + list_known_packages, mem_stats, modify_tags, modules, + package_symbols, packages, regenerate_cgo, remove_dependency, + reset_go_mod_diagnostics, run_go_work_command, run_govulncheck, + run_tests, scan_imports, split_package, tidy, update_go_sum, + upgrade_dependency, vendor, vulncheck, workspace_stats + +AI-POWERED ~ + +:GoAI [-f] {natural_language_request} *:GoAI* + Translate a natural language request into the correct go.nvim + command using an LLM (Copilot or OpenAI-compatible). + Supports visual range — range is forwarded to range-capable commands. + If no argument is given, opens an interactive prompt. + + Flags: + -f Include the full command catalog in the + prompt for better accuracy (uses more + tokens). + + Examples: > + :GoAI run unit test for tags test + :GoAI -f format file with gofumpt + :GoAI add json tags to struct + :'<,'>GoAI convert this json to a go struct +< + Configure via the |ai| option. + +:GoCodeReview [-d [{branch}]] [-b] [-m [{message}]] *:GoCodeReview* + Review Go code with AI and populate the quickfix list with + actionable findings (errors, warnings, suggestions). + Uses the configured AI provider (see |ai| option). + + Flags: + -d [{branch}] Review only changes (diff) against a + branch. If no branch is given, the default + branch (main/master) is auto-detected. + -b, --brief Use a compact prompt that focuses on bugs, + correctness, and major issues (saves + tokens). + -m [{message}] Provide a change description so the + reviewer can give context-aware feedback. + If text follows -m, it is used directly. + Literal \n in the text is converted to + real newlines. + If -m is given alone, a floating editor + (guihua.textview) opens for multi-line + input. Submit with , cancel with q. + + Modes: + :GoCodeReview Review the entire current file. + :'<,'>GoCodeReview Review the visual selection only. + :GoCodeReview -d Review only changes (diff) against + the default branch (main/master, + auto-detected). + :GoCodeReview -d develop + Review changes against a specific + branch. + :GoCodeReview -b Review with brief/compact prompt. + :GoCodeReview -m add lru cache, remove fifo + Provide inline change description. + :GoCodeReview -m Open floating editor for multi-line + change description. + :GoCodeReview -d -m refactor error handling + Diff review with change context. + + Findings are parsed into vim quickfix format with severity: + error (E), warning (W), or suggestion (I). + +:GoAIChat [{question}] *:GoAIChat* + Ask questions about Go code with AI assistance. The response + is displayed in a floating window. + + Code context is automatically included: + - With a visual selection: the selected code is sent. + - Cursor inside a function: the enclosing function text and + LSP references/callers are gathered and sent. + - Otherwise: no code context (general question). + + Tab completion provides common prompts. + Requires `ai = { enable = true }` (see |ai| option). + + Examples: > + :GoAIChat explain this code + :'<,'>GoAIChat check for bugs + :GoAIChat refactor this code + :GoAIChat check concurrency safety + :GoAIChat suggest improvements + :GoAIChat create a commit summary +< + +:GoDocAI [{query}] *:GoDocAI* + Find a Go function, type, or symbol by a vague or partial name + and generate rich AI documentation from its source code. + Uses multiple strategies to locate the symbol: + 1. `go doc` / `go doc -src` for exact or partial matches + 2. gopls workspace/symbol for fuzzy lookup + + The AI then produces comprehensive Markdown documentation + (signature, description, parameters, return values, examples) + displayed in a floating window. + + If no argument is given, uses the word under the cursor. + Requires `ai = { enable = true }` (see |ai| option). + + Examples: > + :GoDocAI Println + :GoDocAI http.ListenAndServe + :GoDocAI json Marshal +< ============================================================================== OPTIONS *go-nvim-options* You can setup go.nvim with following options: - +> { - goimports = "gopls", -- if set to 'gopls' will use gopls format, also goimports - gofmt = "gofumpt", -- if set to gopls will use gopls format - max_line_len = 120, - tag_transform = false, - test_dir = "", + disable_defaults = false, -- true: disable all default settings + remap_commands = {}, -- remap or disable commands, e.g. {GoFmt="GoFormat", GoDoc=false} + go = "go", -- go binary, e.g. "go1.21" + goimports = "gopls", -- "gopls" uses gopls format, also "goimports" + fillstruct = "gopls", -- "gopls" or "fillstruct" + gofmt = "gopls", -- "gopls", "gofumpt", "gofmt", "golines" + max_line_len = 0, -- max line length for golines (0 = disabled) + tag_transform = false, -- gomodifytags transform, e.g. "snakecase" + tag_options = "json=omitempty", -- gomodifytags default tag options + gotests_template = "", -- gotests -template parameter + gotests_template_dir = "", -- gotests -template_dir parameter + gotest_case_exact_match = true, -- exact match test case names comment_placeholder = "  ", - icons = { breakpoint = "🧘", currentpos = "🏃" }, -- set to false to disable - -- this option + icons = { breakpoint = "🧘", currentpos = "🏃" }, -- false to disable + sign_priority = 7, verbose = false, log_path = vim.fn.expand("$HOME") .. "/tmp/gonvim.log", lsp_cfg = false, -- false: do nothing -- true: apply non-default gopls setup defined in go/lsp.lua - -- if lsp_cfg is a table, merge table with with non-default gopls setup in go/lsp.lua, e.g. - lsp_gofumpt = false, -- true: set default gofmt in gopls format to gofumpt - lsp_on_attach = nil, -- nil: do nothing - -- if lsp_on_attach is a function: use this function as on_attach function for gopls, - -- when lsp_cfg is true - lsp_keymaps = true, -- true: apply default lsp keymaps + -- table: merge with non-default gopls setup + lsp_gofumpt = false, -- set default gofmt in gopls to gofumpt + lsp_semantic_highlights = false, -- use highlights from gopls + lsp_on_attach = nil, -- nil: use default on_attach from go/lsp.lua + -- function: use as on_attach for gopls when lsp_cfg is true + lsp_on_client_start = nil, -- called at end of on_attach + lsp_document_formatting = true, -- use gopls to format + lsp_keymaps = true, -- apply default lsp keymaps lsp_codelens = true, - diagnostic = { -- set diagnostic to false to disable vim.diagnostic setup - -- in go.nvim - hdlr = false, -- hook lsp diag handler and send diag to quickfix - underline = true, - -- virtual text setup - virtual_text = { spacing = 0, prefix = '■' }, - signs = true, - update_in_insert = false, - }, + diagnostic = false, -- false: disable diagnostic setup from go.nvim + -- diagnostic = { + -- hdlr = false, + -- underline = true, + -- virtual_text = { spacing = 0, prefix = "■" }, + -- signs = true, + -- update_in_insert = false, + -- }, lsp_inlay_hints = { enable = true, }, + lsp_diag_update_in_insert = false, + lsp_fmt_async = false, -- async lsp.buf.format + gopls_cmd = nil, -- custom gopls path, e.g. { "/path/to/gopls" } gopls_remote_auto = true, gocoverage_sign = "█", - sign_priority = 7, + gocoverage_skip_covered = false, + sign_covered_hl = "String", + sign_partial_hl = "WarningMsg", + sign_uncovered_hl = "Error", + launch_json = nil, -- launch.json path, default: .vscode/launch.json dap_debug = true, - dap_debug_gui = true, - dap_debug_keymap = true, -- true: use keymap for debugger defined in go/dap.lua - -- false: do not use keymap in go/dap.lua. you must define your own. - -- windows: use visual studio style of keymap - dap_vt = true, -- false, true and 'all frames' - textobjects = true, - gopls_cmd = nil, --- you can provide gopls path and cmd if it not in PATH, e.g. cmd = { "/home/ray/.local/nvim/data/lspinstall/go/gopls" } - build_tags = "", --- you can provide extra build tags for tests or debugger - test_runner = "go", -- one of {`go`, `richgo`, `dlv`, `ginkgo`} - run_in_floaterm = false, -- set to true to run in float window. - luasnip = false, -- set true to enable included luasnip - iferr_vertical_shift = 4 -- defines where the cursor will end up vertically from the begining of if err statement after GoIfErr command + dap_debug_gui = {}, -- bool|table for dap-ui setup, false to disable + dap_debug_keymap = true, -- true: use keymaps from go/dap.lua + dap_debug_vt = { enabled_commands = true, all_frames = true }, + dap_port = 38697, -- number or -1 for random port + dap_timeout = 15, + dap_retries = 20, + dap_enrich_config = nil, + build_tags = "", -- extra build tags for tests or debugger + textobjects = true, -- treesitter text objects + test_runner = "go", -- one of {"go", "richgo", "dlv", "ginkgo", "gotestsum"} + run_in_floaterm = false, -- run commands in float window + floaterm = { + posititon = "auto", -- "top", "bottom", "left", "right", "center", "auto" + width = 0.45, + height = 0.98, + title_colors = "nord", + }, + trouble = false, -- use trouble.nvim to open quickfix + test_efm = false, -- errorformat for quickfix + luasnip = false, -- enable included luasnip snippets + username = "", + useremail = "", + disable_per_project_cfg = false, -- disable .gonvim/init.lua loading + iferr_vertical_shift = 4, + iferr_less_highlight = false, -- gray out "if err != nil" statements + on_jobstart = function(cmd) end, + on_stdout = function(err, data) end, + on_stderr = function(err, data) end, + on_exit = function(code, signal, output) end, + ai = { *ai* + enable = false, -- set to true to enable AI features (GoAI, GoCmtAI, GoCodeReview, GoDocAI) + provider = "copilot", -- "copilot" or "openai" + model = nil, -- default: "gpt-4o" (copilot), "gpt-4o-mini" (openai) + api_key_env = "OPENAI_API_KEY", -- env var name that holds the API key + base_url = nil, -- for OpenAI-compatible endpoints + confirm = true, -- confirm before executing translated command + }, } +< vim:tw=78:ts=8:sts=8:sw=8:ft=help:norl:expandtab diff --git a/lua/go.lua b/lua/go.lua index 65793ebc9..792de87f9 100644 --- a/lua/go.lua +++ b/lua/go.lua @@ -165,6 +165,18 @@ _GO_NVIM_CFG = { end, -- callback for jobexit, output : string iferr_vertical_shift = 4, -- defines where the cursor will end up vertically from the begining of if err statement after GoIfErr command iferr_less_highlight = false, -- set to true to make 'if err != nil' statements less highlighted (grayed out) + ai = { + enable = false, -- set to true to enable AI features (GoAI, GoCmtAI) + provider = 'copilot', -- 'copilot' or 'openai' (any OpenAI-compatible endpoint) + model = nil, -- model name, default: 'gpt-4o' for copilot, 'gpt-4o-mini' for openai + api_key_env = 'OPENAI_API_KEY', -- env var name that holds the API key + base_url = nil, -- for openai-compatible APIs, e.g.: 'https://api.openai.com/v1' + confirm = true, -- confirm before executing the translated command + }, + mcp = { + enable = false, -- set to true to enable gopls MCP features + gopls_cmd = {'gopls', 'mcp'}, -- you can provide gopls path and cmd if it not in PATH, e.g. cmd = { "/home/ray/.local/nvim/data/lspinstall/go/gopls", "mcp" } + }, } -- TODO: nvim_{add,del}_user_command https://github.com/neovim/neovim/pull/16752 diff --git a/lua/go/ai.lua b/lua/go/ai.lua new file mode 100644 index 000000000..e6169becd --- /dev/null +++ b/lua/go/ai.lua @@ -0,0 +1,1152 @@ +-- LLM-powered natural language command dispatcher for go.nvim +-- Usage: :GoAI run unit test for tags test +-- :GoAI (interactive prompt) + +local M = {} + +local utils = require('go.utils') +local log = utils.log +local prompts = require('go.prompts') + +-- Cached Copilot API token +local _copilot_token = nil +local _copilot_token_expires = 0 + +-- stylua: ignore start +local valid_cmd_set = {} +for _, c in ipairs({ + 'GoTest', 'GoTestFunc', 'GoTestFile', 'GoTestPkg', 'GoTestSubCase', 'GoTestSum', 'GoAddTest', + 'GoAddExpTest', 'GoAddAllTest', 'GoCoverage', 'GoBuild', 'GoRun', 'GoGenerate', 'GoVet', + 'GoLint', 'GoMake', 'GoStop', 'GoFmt', 'GoImports', 'GoIfErr', 'GoFillStruct', + 'GoFillSwitch', 'GoFixPlurals', 'GoCmt', 'GoImpl', 'GoEnum', 'GoGenReturn', 'GoJson2Struct', + 'GoAddTag', 'GoRmTag', 'GoClearTag', 'GoModifyTag', 'GoModTidy', 'GoModVendor', 'GoModDnld', + 'GoModGraph', 'GoModWhy', 'GoModInit', 'GoGet', 'GoWork', 'GoDoc', 'GoDocBrowser', + 'GoAlt', 'GoAltV', 'GoAltS', 'GoImplements', 'GoPkgOutline', 'GoPkgSymbols', 'GoListImports', + 'GoCheat', 'GoCodeAction', 'GoCodeLenAct', 'GoRename', 'GoGCDetails', 'GoDebug', 'GoBreakToggle', + 'GoBreakSave', 'GoBreakLoad', 'GoDbgStop', 'GoDbgContinue', 'GoDbgKeys', 'GoCreateLaunch', 'DapStop', + 'DapRerun', 'BreakCondition', 'LogPoint', 'ReplRun', 'ReplToggle', 'ReplOpen', 'GoInstallBinary', + 'GoUpdateBinary', 'GoInstallBinaries', 'GoUpdateBinaries', 'GoTool', 'GoMockGen', 'GoEnv', + 'GoProject', 'GoToggleInlay', 'GoVulnCheck', 'GoNew', 'Gomvp', 'Ginkgo', + 'GinkgoFunc', 'GinkgoFile', 'GoGopls', 'GoCmtAI', 'GoCodeReview', 'GoDocAI', +}) do + valid_cmd_set[c] = true +end +-- stylua: ignore end + +local command_catalog = [[ +go.nvim Commands Reference: + +TESTING: +- GoTest [args] — Run tests. Args: package path, -v (verbose), -tags=xxx, -bench, -run=pattern, -count=N +- GoTestFunc [args] — Run test function under cursor. Args: -v, -tags=xxx +- GoTestFile [args] — Run all tests in current file. Args: -v, -tags=xxx +- GoTestPkg [args] — Run all tests in current package. Args: -v, -tags=xxx +- GoTestSubCase — Run specific table-driven test sub-case under cursor +- GoTestSum [args] — Run tests with gotestsum. Args: -w (watch mode) +- GoAddTest — Generate test for function under cursor +- GoAddExpTest — Generate tests for all exported functions +- GoAddAllTest — Generate tests for all functions +- GoCoverage [args] — Run tests with coverage display. Args: package path, -t (toggle), -f (file) + +BUILD & RUN: +- GoBuild [args] — Build project. Args: package path, e.g. ./... +- GoRun [args] — Run the program. Args: any arguments to pass +- GoGenerate [args] — Run go generate. Args: package path +- GoVet [args] — Run go vet. Args: package path +- GoLint [args] — Run golangci-lint. Args: package path +- GoMake — Async make +- GoStop [job] — Stop a running async job + +FORMAT & IMPORTS: +- GoFmt [formatter] — Format file. Args: gofmt, goimports, gofumpt, golines +- GoImports [pkg] — Add/remove imports. Args: optional package to import + +CODE GENERATION: +- GoIfErr — Generate 'if err != nil' boilerplate at cursor +- GoFillStruct — Fill struct literal with default field values +- GoFillSwitch — Fill switch statement with all type/enum cases +- GoFixPlurals — Merge consecutive same-type function parameters +- GoCmt — Generate doc comment for function/struct under cursor +- GoImpl — Generate interface stubs. E.g. GoImpl f *File io.Reader +- GoEnum [args] — Generate enum helpers +- GoGenReturn — Generate return values for function call under cursor +- GoJson2Struct [name] — Convert JSON to Go struct. Args: struct name + +STRUCT TAGS: +- GoAddTag [tags] — Add struct tags. Args: json, xml, yaml, db, etc. +- GoRmTag [tags] — Remove struct tags. Args: tag names +- GoClearTag — Remove all struct tags +- GoModifyTag [tag] [transform] — Modify tag options. Args: tag name, transform (snakecase, camelcase) + +MODULE MANAGEMENT: +- GoModTidy — Run go mod tidy +- GoModVendor — Run go mod vendor +- GoModDnld — Run go mod download +- GoModGraph — Show module dependency graph +- GoModWhy [module] — Show why a module is needed +- GoModInit [name] — Initialize new Go module +- GoGet [pkg] — Run go get. Args: package path +- GoWork [cmd] [path] — Go workspace commands. Args: run, use + +NAVIGATION & DOCS: +- GoDoc [symbol] — Show documentation. Args: package or symbol name +- GoDocBrowser [symbol] — Open docs in browser +- GoAlt — Switch between test and implementation file +- GoAltV — Switch to alternate file in vertical split +- GoAltS — Switch to alternate file in horizontal split +- GoImplements — Show interface implementations via LSP +- GoPkgOutline [pkg] — Show package outline +- GoPkgSymbols — Show package symbols +- GoListImports — List imports in current file +- GoCheat [topic] — Cheat sheet from cht.sh + +CODE ACTIONS & REFACTORING: +- GoCodeAction — Run LSP code actions (visual range supported) +- GoCodeLenAct — Run code lens action +- GoRename — Rename symbol via LSP +- GoGCDetails — Toggle GC optimization details + +DEBUGGING (requires nvim-dap): +- GoDebug [args] — Start debugger. Args: -t (test), -r (restart), -n (nearest test), -f (file), -p (package), -s (stop), -b (breakpoint) +- GoBreakToggle — Toggle breakpoint at cursor line +- GoBreakSave — Save breakpoints to file +- GoBreakLoad — Load saved breakpoints +- GoDbgStop — Stop debugger +- GoDbgContinue — Continue execution in debugger +- GoDbgKeys — Show debugger key mappings +- GoCreateLaunch — Create .vscode/launch.json +- DapStop — Stop DAP session +- DapRerun — Rerun last DAP session + +TOOLS & INSTALL: +- GoInstallBinary [tool] — Install a Go tool binary +- GoUpdateBinary [tool] — Update a Go tool binary +- GoInstallBinaries — Install all required tool binaries +- GoUpdateBinaries — Update all tool binaries +- GoTool [cmd] — Run go tool sub-command + +MOCK: +- GoMockGen [args] — Generate mocks. Args: -p (package), -d (destination), -i (interface), -s (source) + +OTHER: +- GoEnv [file] — Load environment variables from file +- GoProject — Setup project configuration +- GoToggleInlay — Toggle LSP inlay hints +- GoVulnCheck — Run govulncheck for vulnerability scanning +- GoNew [template] — Create project from template +- Gomvp [old] [new] — Rename/move packages +- Ginkgo [cmd] — Ginkgo framework. Args: generate, bootstrap, build, labels, run, watch +- GinkgoFunc — Run Ginkgo test for current function +- GinkgoFile — Run Ginkgo tests for current file + +GOPLS LSP COMMANDS (via GoGopls [json_args]): +- GoGopls add_dependency {"GoCmdArgs":["pkg@version"]} — Add a module dependency +- GoGopls add_import {"ImportPath":"fmt"} — Add an import to the current file +- GoGopls add_test — Generate a test for the function at cursor +- GoGopls apply_fix — Apply a suggested fix +- GoGopls assembly — Show assembly for a function +- GoGopls change_signature — Refactor a function signature (remove/reorder params) +- GoGopls check_upgrades — Check for module dependency upgrades +- GoGopls diagnose_files — Run diagnostics on specified files +- GoGopls doc — Open Go documentation for symbol at cursor +- GoGopls edit_go_directive — Edit the go directive in go.mod +- GoGopls extract_to_new_file — Extract selected code to a new file +- GoGopls free_symbols — List free symbols in a selection +- GoGopls gc_details — Toggle GC optimization details overlay +- GoGopls generate — Run go generate for the current file/package +- GoGopls go_get_package — Run go get for a package +- GoGopls list_imports — List all imports in the current file +- GoGopls list_known_packages — List all known/importable packages +- GoGopls mem_stats — Show gopls memory statistics +- GoGopls modify_tags — Add/remove/modify struct field tags +- GoGopls modules — List modules in the workspace +- GoGopls package_symbols — List symbols in a package +- GoGopls packages — List packages in the workspace +- GoGopls regenerate_cgo — Regenerate cgo definitions +- GoGopls remove_dependency — Remove a module dependency +- GoGopls reset_go_mod_diagnostics — Reset go.mod diagnostics +- GoGopls run_go_work_command — Run a go work command +- GoGopls run_govulncheck — Run govulncheck via gopls +- GoGopls run_tests — Run tests via gopls +- GoGopls scan_imports — Scan for available imports +- GoGopls split_package — Split a package into multiple packages +- GoGopls tidy — Run go mod tidy +- GoGopls update_go_sum — Update go.sum +- GoGopls upgrade_dependency — Upgrade a module dependency +- GoGopls vendor — Run go mod vendor +- GoGopls vulncheck — Run vulnerability check +- GoGopls workspace_stats — Show workspace statistics + +AI-POWERED: +- GoCmtAI — Generate doc comment for the declaration at cursor using AI +- GoCodeReview — Review the current Go file (or visual selection) with AI; outputs findings to the vim quickfix list. Args: -d [branch] (diff mode), -b (brief), -m (change description) +- GoDocAI [query] — Find a function/type by vague name and generate rich AI documentation from its source code +]] + +local system_prompt_base = [[You are a command translator for go.nvim, a Neovim plugin for Go development. +Your job is to translate natural language requests into the correct go.nvim Vim command. + +Rules: +1. Return ONLY the Vim command to execute. No explanation, no markdown, no backticks, no extra text. +2. The command must start with one of the go.nvim commands from the reference below. +3. Include any necessary arguments exactly as they would be typed in the Vim command line. +4. If the request is ambiguous, choose the most likely command. +5. If the request cannot be mapped to any go.nvim command, return exactly: echo "No matching go.nvim command found" + +]] + +local system_prompt = system_prompt_base .. command_catalog + +--- Read Copilot OAuth token from the config files written by copilot.vim / copilot.lua +local function get_copilot_oauth_token() + local paths = { + vim.fn.expand('~/.config/github-copilot/hosts.json'), + vim.fn.expand('~/.config/github-copilot/apps.json'), + } + + for _, path in ipairs(paths) do + local f = io.open(path, 'r') + if f then + local content = f:read('*a') + f:close() + local ok, data = pcall(vim.json.decode, content) + if ok and type(data) == 'table' then + for _, v in pairs(data) do + if type(v) == 'table' and v.oauth_token then + return v.oauth_token + end + end + end + end + end + return nil +end + +--- Parse a curl exit code into a human-readable error message +local function parse_curl_error(exit_code, stderr) + local curl_errors = { + [6] = 'DNS resolution failed - check your network connection', + [7] = 'connection refused - API server may be down', + [28] = 'request timed out - network may be slow or unreachable', + [35] = 'SSL/TLS handshake failed', + [51] = 'SSL certificate verification failed', + [52] = 'server returned empty response', + [56] = 'network data receive error - connection may have been reset', + } + local msg = curl_errors[exit_code] + if msg then + return msg + end + return string.format('curl error %d: %s', exit_code, (stderr or ''):gsub('%s+$', '')) +end + +--- Split curl output (with -w '\n%%{http_code}') into body and status code +local function split_http_response(stdout) + local code = stdout:match('(%d+)%s*$') + local body = code and stdout:sub(1, -(#code + 2)) or stdout + return body, code or '0' +end + +--- Exchange OAuth token for short-lived Copilot API token (cached) +local function get_copilot_api_token(oauth_token, callback) + if _copilot_token and os.time() < _copilot_token_expires then + callback(_copilot_token) + return + end + + -- stylua: ignore start + vim.system({ + 'curl', '-s', '--connect-timeout', '10', + '--max-time', '15', '-w', '\n%{http_code}', + '-H', 'Authorization: token ' .. oauth_token, '-H', 'Accept: application/json', + 'https://api.github.com/copilot_internal/v2/token', + }, { text = true }, function(result) + -- stylua: ignore end + vim.schedule(function() + if result.code ~= 0 then + local msg = parse_curl_error(result.code, result.stderr) + vim.notify('go.nvim [AI]: Copilot token request failed: ' .. msg, vim.log.levels.ERROR) + return + end + local stdout = result.stdout or '' + local body, http_code = split_http_response(stdout) + if http_code ~= '200' then + vim.notify( + 'go.nvim [AI]: Copilot token request returned HTTP ' .. http_code .. ': ' .. body:sub(1, 200), + vim.log.levels.ERROR + ) + return + end + local ok, data = pcall(vim.json.decode, body) + if ok and data and data.token then + _copilot_token = data.token + _copilot_token_expires = (data.expires_at or 0) - 60 -- refresh 60s early + callback(data.token) + else + vim.notify('go.nvim [AI]: unexpected Copilot token response', vim.log.levels.ERROR) + end + end) + end) +end + +--- Generic helper: POST a chat completion request via curl +local function call_chat_api(url, headers, body, callback) + local cmd = { 'curl', '-s', '--connect-timeout', '10', '--max-time', '30', '-w', '\n%{http_code}', '-X', 'POST' } + for _, h in ipairs(headers) do + table.insert(cmd, '-H') + table.insert(cmd, h) + end + table.insert(cmd, '-d') + table.insert(cmd, '@-') -- read body from stdin + table.insert(cmd, url) + + vim.system(cmd, { text = true, stdin = body }, function(result) + vim.schedule(function() + if result.code ~= 0 then + local msg = parse_curl_error(result.code, result.stderr) + vim.notify('go.nvim [AI]: API request failed: ' .. msg, vim.log.levels.ERROR) + return + end + local stdout = result.stdout or '' + local resp_body, http_code = split_http_response(stdout) + if http_code ~= '200' then + local detail = resp_body:sub(1, 200) + -- Try to extract error message from JSON response + local ok_json, err_data = pcall(vim.json.decode, resp_body) + if ok_json and type(err_data) == 'table' and err_data.error then + local e = err_data.error + detail = type(e) == 'table' and (e.message or vim.inspect(e)) or tostring(e) + end + vim.notify('go.nvim [AI]: HTTP ' .. http_code .. ': ' .. detail, vim.log.levels.ERROR) + return + end + local ok, data = pcall(vim.json.decode, resp_body) + if ok and data and data.choices and data.choices[1] and data.choices[1].message then + callback(vim.trim(data.choices[1].message.content)) + else + vim.notify('go.nvim [AI]: unexpected API response: ' .. resp_body:sub(1, 200), vim.log.levels.ERROR) + end + end) + end) +end + +--- Build the user message with workspace context +local function build_user_message(request) + local file = vim.fn.expand('%:t') or '' + local ft = vim.bo.filetype or '' + return string.format('Current file: %s (filetype: %s)\nRequest: %s', file, ft, request) +end + +--- Build the JSON body for a chat completion +local function build_body(model, sys_prompt, user_msg, opts) + opts = opts or {} + return vim.json.encode({ + model = model, + messages = { + { role = 'system', content = sys_prompt }, + { role = 'user', content = user_msg }, + }, + temperature = opts.temperature or 0, + max_tokens = opts.max_tokens or 200, + }) +end + +--- Validate that the LLM response is a known go.nvim command +local function validate_response(cmd_str) + -- Allow echo for "no match" responses + if cmd_str:match('^echo ') then + return true + end + local cmd_name = cmd_str:match('^:?(%S+)') + return cmd_name and valid_cmd_set[cmd_name] == true +end + +-- Commands that accept a visual range +local range_commands = { + GoCodeAction = true, + GoJson2Struct = true, +} + +--- Process the LLM response: validate, confirm, execute +local function handle_response(cmd_str, confirm, range_prefix) + -- Strip markdown fences if the LLM wrapped the answer + cmd_str = cmd_str:gsub('^```%w*\n?', ''):gsub('\n?```$', '') + cmd_str = vim.trim(cmd_str) + -- take only the first line + cmd_str = cmd_str:match('^([^\n]+)') or cmd_str + + log('go.nvim [AI]: LLM response:', cmd_str) + + if not validate_response(cmd_str) then + log('go.nvim [AI]: unrecognised command:', cmd_str) + vim.notify('go.nvim [AI]: unrecognised command: ' .. cmd_str, vim.log.levels.WARN) + return + end + + -- echo is informational, just show it + if cmd_str:match('^echo ') then + vim.cmd(cmd_str) + return + end + + -- Prepend range prefix for commands that support visual ranges + if range_prefix and range_prefix ~= '' then + local cmd_name = cmd_str:match('^:?(%S+)') + if cmd_name and range_commands[cmd_name] then + cmd_str = range_prefix .. cmd_str + end + end + + log('go.nvim [AI]: executing:', cmd_str) + + if not confirm then + vim.cmd(cmd_str) + return + end + + vim.ui.select({ 'Yes', 'Edit', 'No' }, { + prompt = string.format('Run %s ?', cmd_str), + }, function(choice) + if choice == 'Yes' then + vim.cmd(cmd_str) + elseif choice == 'Edit' then + vim.api.nvim_feedkeys(':' .. cmd_str, 'n', false) + end + end) +end + +--- Send request via GitHub Copilot Chat API (generic) +local function send_copilot_raw(sys_prompt, user_msg, opts, callback) + local oauth = get_copilot_oauth_token() + if not oauth then + vim.notify( + 'go.nvim [AI]: Copilot OAuth token not found. Please install copilot.vim or copilot.lua and run :Copilot auth', + vim.log.levels.ERROR + ) + return + end + + get_copilot_api_token(oauth, function(token) + local cfg = _GO_NVIM_CFG.ai or {} + local model = cfg.model or 'gpt-4o' + log('build_body with model', model, 'sys_prompt ', sys_prompt, 'user_msg', user_msg, 'opts', opts) + local body = build_body(model, sys_prompt, user_msg, opts) + local nvim_ver = string.format('%s.%s.%s', vim.version().major, vim.version().minor, vim.version().patch) + local headers = { + 'Content-Type: application/json', + 'Authorization: Bearer ' .. token, + 'Copilot-Integration-Id: vscode-chat', + 'Editor-Version: Neovim/' .. nvim_ver, + 'Editor-Plugin-Version: go.nvim/1.0.0', + 'User-Agent: go.nvim/1.0.0', + } + call_chat_api('https://api.githubcopilot.com/chat/completions', headers, body, callback) + end) +end + +--- Send request via OpenAI-compatible API (generic) +local function send_openai_raw(sys_prompt, user_msg, opts, callback) + local cfg = _GO_NVIM_CFG.ai or {} + local env_name = cfg.api_key_env or 'OPENAI_API_KEY' + local api_key = os.getenv(env_name) + local base_url = cfg.base_url or 'https://api.openai.com/v1' + local model = cfg.model or 'gpt-4o-mini' + + if not api_key or api_key == '' then + vim.notify('go.nvim [AI]: API key not found. Set the ' .. env_name .. ' environment variable', vim.log.levels.ERROR) + return + end + + local body = build_body(model, sys_prompt, user_msg, opts) + local headers = { + 'Content-Type: application/json', + 'Authorization: Bearer ' .. api_key, + } + call_chat_api(base_url .. '/chat/completions', headers, body, callback) +end + +--- Entry point: :GoAI run unit test for tags test +function M.run(opts) + local cfg = _GO_NVIM_CFG.ai or {} + if not cfg.enable then + vim.notify( + 'go.nvim [AI]: AI is disabled. Set ai = { enable = true } in go.nvim setup to use GoAI', + vim.log.levels.WARN + ) + return + end + + local prompt + local range_prefix = '' + + local full_catalog = false + + if type(opts) == 'table' then + local fargs = opts.fargs or {} + -- Check for -f flag to include full command catalog + local filtered = {} + for _, arg in ipairs(fargs) do + if arg == '-f' then + full_catalog = true + else + table.insert(filtered, arg) + end + end + prompt = table.concat(filtered, ' ') + -- Capture visual range if the command was called with one + if opts.range and opts.range == 2 then + range_prefix = string.format('%d,%d', opts.line1, opts.line2) + end + else + -- Legacy: called with varargs + prompt = opts or '' + end + + if prompt == '' then + vim.ui.input({ prompt = 'go.nvim AI> ' }, function(input) + if input and input ~= '' then + M._dispatch(input, range_prefix, full_catalog) + end + end) + return + end + + M._dispatch(prompt, range_prefix, full_catalog) +end + +--- Dispatch the natural language request to the configured LLM provider +function M._dispatch(prompt, range_prefix, full_catalog) + local cfg = _GO_NVIM_CFG.ai or {} + local provider = cfg.provider or 'copilot' + local confirm = cfg.confirm ~= false -- default true + + vim.notify('go.nvim [AI]: thinking …', vim.log.levels.INFO) + + local sys_prompt = full_catalog and system_prompt or system_prompt_base + local user_msg = build_user_message(prompt) + + local function on_resp(resp) + handle_response(resp, confirm, range_prefix) + end + + if provider == 'copilot' then + send_copilot_raw(sys_prompt, user_msg, {}, on_resp) + elseif provider == 'openai' then + send_openai_raw(sys_prompt, user_msg, {}, on_resp) + else + vim.notify('go.nvim [AI]: unknown provider "' .. provider .. '"', vim.log.levels.ERROR) + end +end + +-- ─── GoCodeReview ──────────────────────────────────────────────────────────── + +--- Detect the default branch of the repository. +--- @return string +local function detect_default_branch() + -- Check remote HEAD first (most reliable) + local h = vim.fn.systemlist('git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null') + if vim.v.shell_error == 0 and h[1] then + local branch = h[1]:match('refs/remotes/origin/(.+)') + if branch then + return branch + end + end + -- Fallback: check if main or master exists locally + for _, name in ipairs({ 'main', 'master' }) do + vim.fn.system('git rev-parse --verify ' .. name .. ' 2>/dev/null') + if vim.v.shell_error == 0 then + return name + end + end + return 'main' +end + +--- Get the unified diff of a file against a branch. +--- @param filepath string Absolute path to the file +--- @param branch string Branch name to diff against +--- @param callback function Called with (diff_text, err_msg) +local function get_git_diff(filepath, branch, callback) + local rel = vim.fn.fnamemodify(filepath, ':.') + vim.system({ 'git', 'diff', '-U10', branch .. '...HEAD', '--', rel }, { text = true }, function(result) + vim.schedule(function() + if result.code ~= 0 then + callback(nil, 'git diff failed: ' .. (result.stderr or ''):gsub('%s+$', '')) + return + end + local diff = vim.trim(result.stdout or '') + if diff == '' then + callback(nil, 'no changes against ' .. branch) + return + end + callback(diff, nil) + end) + end) +end + +-- stylua: ignore start +local code_review_system_prompt = + [[You are an experienced Golang code reviewer. Your task is to review Go language source code for correctness, readability, performance, best practices, and style. Carefully analyze the given Go code snippet or file and provide specific actionable feedback to improve quality. Identify issues such as bugs, inefficient constructs, poor naming, inconsistent formatting, concurrency pitfalls, error handling mistakes, or deviations from idiomatic Go. Suggest precise code changes and explain why they improve the code. + +When reviewing, reason step-by-step about each aspect of the code before concluding. Be polite, professional, and constructive. +]] + .. prompts.review_guidelines() + .. [[ + +# Instructions + +1. Read the entire Go code provided. +2. For each audit category above, check whether any issues apply. +3. For each Go-specific review dimension, check whether any issues apply. +4. Assess functionality and correctness. +5. Evaluate code readability and style against Go conventions. +6. Check for performance or concurrency issues. +7. Review error handling and package usage. +8. Provide only actionable improvements — skip praise or explanations of what is already good. + +# Output Format + +The source code is provided with explicit line markers in the format "L|" at the start of each line. +For example: + L10| func main() { + L11| os.Getenv("KEY") + L12| } +If you find an issue on the line starting with "L11|", you MUST output line number 11. + +If there are NO improvements needed: +- Output exactly one line: a brief overall summary (e.g. "Code looks idiomatic and correct."). + +If there ARE improvements, output ONLY lines in vim quickfix format: + ::: : [] : . Refactor: +where is: + error — compile errors and logic errors only (code will not build or produces wrong results) + warning — issues that must be handled for production: memory leaks, heap escapes, missing/incorrect timeouts, unclosed resources, unhandled signals, etc. + info — all other improvements: style, naming, readability, idiomatic Go, minor refactors, etc. +and is one of: Effective Go, 100 Go Mistakes, Google Style + +Example input: + L41| params := map[string]string{ + L42| "ProjectID": projectID, + L43| "ServingConfig": os.Getenv("SERVING_CONFIG"), + L44| } + +CRITICAL: Read the "L|" prefix of the EXACT line containing the issue. That number is the line number you must use. Do NOT use the line number of a nearby or enclosing line. + +Rules: +- Do NOT output any introduction, summary header, markdown, or conclusion. +- Do NOT use code blocks or bullet points. +- Each issue must be a separate line in the exact quickfix format above. +- Line numbers MUST match the prefixed line numbers in the provided code. If exact line is unknown, use line 1. +- Focus on practical, specific improvements only. + +If code is not provided, output exactly: error: no Go source code provided for review. +]] + +local code_review_system_prompt_short = + [[You are an experienced Go code reviewer. Review the given Go code for bugs, correctness, error handling, concurrency issues, and major style problems. Focus on actionable issues only. + +The source code has line markers "L|" at the start of each line. Use those numbers for line references. + +If there are NO issues: output one summary line (e.g. "Code looks correct and idiomatic."). + +If there ARE issues, output ONLY lines in vim quickfix format: + ::: : . Refactor: +where is: error (bugs/compile errors), warning (resource leaks, races, missing cleanup), info (style/naming). + +Rules: +- No markdown, no headers, no bullet points. One quickfix line per issue. +- Line numbers MUST match the L prefixes in the provided code. +]] + +local diff_review_system_prompt_short = + [[You are an experienced Go code reviewer. Review the unified diff for bugs, correctness, error handling, concurrency issues, and major style problems in the changed lines (+ lines) only. + +Use NEW file line numbers from diff hunk headers (@@ -a,b +c,d @@). + +If there are NO issues: output one summary line. + +If there ARE issues, output ONLY lines in vim quickfix format: + ::: : . Refactor: +where is: error, warning, info. + +Rules: +- No markdown, no headers, no bullet points. One quickfix line per issue. +]] + +local diff_review_system_prompt = + [[You are an experienced Golang code reviewer. You are reviewing a unified diff (git diff) of Go source code changes against a base branch. Focus ONLY on the changed lines (lines starting with + or context around them). Evaluate the changes for correctness, readability, performance, best practices, and style. + +IMPORTANT: Use the NEW file line numbers from the diff hunk headers (the second number in @@ -a,b +c,d @@). For added/changed lines (starting with +), compute the actual file line number by counting from the hunk start. +]] + .. prompts.review_guidelines() + .. [[ + +# Instructions + +1. Read the unified diff carefully. +2. Focus only on the added/modified code (+ lines). +3. For each audit category above, check whether any issues apply to the changed code. +4. For each Go-specific review dimension, check whether any issues apply to the changed code. +5. Evaluate changed code for bugs, style, performance, concurrency, error handling. +6. Skip praise — output improvements only. + +# Output Format + +If there are NO improvements needed: +- Output exactly one line: a brief summary (e.g. "Changes look correct and idiomatic."). + +If there ARE improvements, output ONLY lines in vim quickfix format: + ::: : [] : . Refactor: +where is: + error — compile errors and logic errors only (code will not build or produces wrong results) + warning — issues that must be handled for production: memory leaks, heap escapes, missing/incorrect timeouts, unclosed resources, unhandled signals, etc. + info — all other improvements: style, naming, readability, idiomatic Go, minor refactors, etc. +and is one of: Effective Go, 100 Go Mistakes, Google Style + +Rules: +- Do NOT output any introduction, summary header, markdown, or bullet points. +- Each issue must be a separate line in the exact quickfix format above. +- Line numbers must be the NEW file line numbers (post-change), 1-based. +- Focus on practical, specific improvements only. +]] +-- stylua: ignore end + +--- Parse quickfix lines from the LLM response and open the quickfix list. +--- Lines not matching the format are silently skipped. +--- @param response string Raw LLM output +--- @param filename string Absolute path to the reviewed file +local function handle_review_response(response, filename) + response = vim.trim(response) + + -- Strip accidental markdown fences + response = response:gsub('^```%w*\n?', ''):gsub('\n?```$', '') + response = vim.trim(response) + + local lines = vim.split(response, '\n', { plain = true }) + + -- Detect "no issues" case: single non-quickfix line + if #lines == 1 and not lines[1]:match('^[^:]+:%d+:') then + vim.notify('go.nvim [CodeReview]: ' .. lines[1], vim.log.levels.INFO) + return + end + + local qflist = {} + for _, line in ipairs(lines) do + line = vim.trim(line) + if line ~= '' then + -- Try to parse: filename:lnum:col: type: text + -- or filename:lnum: type: text (col optional) + local fname, lnum, col, text = line:match('^([^:]+):(%d+):(%d+):%s*(.+)$') + if not fname then + fname, lnum, text = line:match('^([^:]+):(%d+):%s*(.+)$') + col = 1 + end + if fname and lnum and text then + -- Derive type (E/W/I) from leading severity word + local type_char = 'W' + local severity = text:match('^(%a+):') + if severity then + local sl = severity:lower() + if sl == 'error' then + type_char = 'E' + elseif sl == 'suggestion' or sl == 'info' or sl == 'note' then + type_char = 'I' + end + end + table.insert(qflist, { + filename = filename, + lnum = tonumber(lnum) or 1, + col = tonumber(col) or 1, + text = text, + type = type_char, + }) + else + -- Fallback: treat unrecognised line as a general warning at line 1 + if line ~= '' then + table.insert(qflist, { + filename = filename, + lnum = 1, + col = 1, + text = line, + type = 'W', + }) + end + end + end + end + + if #qflist == 0 then + vim.notify('[GoCodeReview]: great job! No issues found.', vim.log.levels.INFO) + return + end + + vim.fn.setqflist({}, 'r', { title = 'GoCodeReview', items = qflist }) + vim.cmd('copen') + vim.notify(string.format('[GoCodeReview]: %d issue(s) added to quickfix', #qflist), vim.log.levels.INFO) +end + +--- Entry point for :GoCodeReview [-d [branch]] [-b] [-m ] +--- Reviews the current buffer, visual selection, or diff against a branch. +--- :GoCodeReview — review entire file +--- :'<,'>GoCodeReview — review visual selection +--- :GoCodeReview -d — review only changes vs main/master (auto-detected) +--- :GoCodeReview -d develop — review only changes vs 'develop' +--- :GoCodeReview -m add lru cache and remove fifo cache — provide change context +--- @param opts table Standard nvim command opts (range, line1, line2, fargs) +function M.code_review(opts) + local cfg = _GO_NVIM_CFG.ai or {} + if not cfg.enable then + vim.notify( + 'go.nvim [AI]: AI is disabled. Set ai = { enable = true } in go.nvim setup to use GoCodeReview', + vim.log.levels.WARN + ) + return + end + + local fargs = (type(opts) == 'table' and opts.fargs) or {} + + -- Parse flags: -d [branch], -b/--brief, -m + local diff_mode = false + local diff_branch = nil + local brief = false + local change_message = nil + local i = 1 + while i <= #fargs do + local arg = fargs[i] + if arg == '-d' or arg == '--diff' then + diff_mode = true + if fargs[i + 1] and not fargs[i + 1]:match('^%-') then + diff_branch = fargs[i + 1] + i = i + 1 + end + elseif arg == '-b' or arg == '--brief' then + brief = true + elseif arg == '-m' or arg == '--message' then + -- Collect everything after -m as the change description + local msg_parts = {} + for j = i + 1, #fargs do + table.insert(msg_parts, fargs[j]) + end + local raw = table.concat(msg_parts, ' ') + -- Convert literal \n sequences to real newlines + change_message = raw:gsub('\\n', '\n') + break + end + i = i + 1 + end + + local filename = vim.fn.expand('%:p') + + if diff_mode then + local branch = diff_branch or detect_default_branch() + vim.notify('[GoCodeReview]: diffing against ' .. branch .. ' …', vim.log.levels.INFO) + get_git_diff(filename, branch, function(diff, err) + if err then + vim.notify('[GoCodeReview]: ' .. err, vim.log.levels.WARN) + return + end + local short_name = vim.fn.expand('%:t') + local user_msg = '' + if change_message and change_message ~= '' then + user_msg = '## Change Description\n' .. change_message .. '\n\n' + end + user_msg = user_msg .. string.format('File: %s\nBase branch: %s\n\n```diff\n%s\n```', short_name, branch, diff) + local sys = brief and diff_review_system_prompt_short or diff_review_system_prompt + M.request(sys, user_msg, { max_tokens = 1500, temperature = 0 }, function(resp) + handle_review_response(resp, filename) + end) + end) + return + end + + -- Full-file / visual-selection review + local lines + local start_line = 1 + if type(opts) == 'table' and opts.range and opts.range == 2 then + lines = vim.api.nvim_buf_get_lines(0, opts.line1 - 1, opts.line2, false) + start_line = opts.line1 + else + lines = vim.api.nvim_buf_get_lines(0, 0, -1, false) + end + + if #lines == 0 then + vim.notify('[GoCodeReview]: buffer is empty', vim.log.levels.WARN) + return + end + + -- Prefix each line with its file line number so the LLM references exact positions + local numbered = {} + for i_line, line in ipairs(lines) do + table.insert(numbered, string.format('L%d| %s', start_line + i_line - 1, line)) + end + local code = table.concat(numbered, '\n') + local short_name = vim.fn.expand('%:t') + local user_msg = '' + if change_message and change_message ~= '' then + user_msg = '## Change Description\n' .. change_message .. '\n\n' + end + user_msg = user_msg .. string.format('File: %s\n\n```go\n%s\n```', short_name, code) + + vim.notify('[GoCodeReview]: reviewing …', vim.log.levels.INFO) + + local sys = brief and code_review_system_prompt_short or code_review_system_prompt + M.request(sys, user_msg, { max_tokens = 1500, temperature = 0 }, function(resp) + handle_review_response(resp, filename) + end) +end + +-- --------------------------------------------------------------------------- +-- GoAIChat +-- --------------------------------------------------------------------------- + +local chat_system_prompt = [[You are an expert Go developer and code assistant embedded in Neovim via go.nvim. +The user may ask you to explain, examine, refactor, check, or otherwise discuss Go code or general Go questions. + +Guidelines: +- Be concise but thorough. Prefer short paragraphs over long walls of text. +- When showing code, use plain fenced Go blocks (no extra commentary outside the block unless needed). +- When refactoring, show only the changed/relevant portion, not the entire file. +- When explaining, prefer bullet points for lists of properties or steps. +- Get straight to the answer. +- If the user provides a code snippet, treat it as the subject of the question. +- Always assume Go unless the user says otherwise. +]] + +--- Render the chat response in a floating scratch window +--- @param response string +--- @param title string|nil +local function open_chat_float(response, title) + local lines = vim.split(response, '\n', { plain = true }) + + -- Add a blank leading line for padding + table.insert(lines, 1, '') + table.insert(lines, '') + + local width = math.min(math.max(60, vim.o.columns - 20), 120) + local height = math.min(#lines + 2, math.floor(vim.o.lines * 0.7)) + + local buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) + vim.api.nvim_set_option_value('modifiable', false, { buf = buf }) + vim.api.nvim_set_option_value('buftype', 'nofile', { buf = buf }) + vim.api.nvim_set_option_value('filetype', 'markdown', { buf = buf }) + + local row = math.floor((vim.o.lines - height) / 2) + local col = math.floor((vim.o.columns - width) / 2) + + local win = vim.api.nvim_open_win(buf, true, { + relative = 'editor', + width = width, + height = height, + row = row, + col = col, + style = 'minimal', + border = 'rounded', + title = ' ' .. (title or 'GoAIChat') .. ' ', + title_pos = 'center', + }) + vim.api.nvim_set_option_value('wrap', true, { win = win }) + vim.api.nvim_set_option_value('linebreak', true, { win = win }) + + -- Close keymaps + for _, key in ipairs({ 'q', '', '' }) do + vim.keymap.set('n', key, function() + if vim.api.nvim_win_is_valid(win) then + vim.api.nvim_win_close(win, true) + end + end, { buffer = buf, nowait = true, silent = true }) + end +end + +--- Build a user message for GoAIChat, optionally embedding a code snippet +--- @param question string +--- @param code string|nil selected or surrounding code, may be nil +--- @param lang string|nil filetype / language hint +--- @return string +local function build_chat_user_msg(question, code, lang) + lang = lang or 'go' + if code and code ~= '' then + return string.format('%s\n\n```%s\n%s\n```', question, lang, code) + end + return question +end + +--- Get the enclosing function/method node and its text from the buffer. +--- Returns (func_text, func_name) or (nil, nil) if cursor is not inside a function. +local function get_enclosing_func(bufnr) + bufnr = bufnr or vim.api.nvim_get_current_buf() + local ok, ts_utils = pcall(require, 'nvim-treesitter.ts_utils') + if not ok then + ok, ts_utils = pcall(require, 'guihua.ts_obsolete.ts_utils') + if not ok then + return nil, nil + end + end + local current_node = ts_utils.get_node_at_cursor() + if not current_node then + return nil, nil + end + local expr = current_node + while expr do + if expr:type() == 'function_declaration' or expr:type() == 'method_declaration' then + break + end + expr = expr:parent() + end + if not expr then + return nil, nil + end + + local func_text = vim.treesitter.get_node_text(expr, bufnr) or '' + + -- Extract function name + local name_node = expr:field('name') + local func_name = '' + if name_node and name_node[1] then + func_name = vim.treesitter.get_node_text(name_node[1], bufnr) or '' + end + + return func_text, func_name +end + +--- Entry point for :GoAIChat +--- @param opts table Standard nvim command opts +function M.chat(opts) + local cfg = _GO_NVIM_CFG.ai or {} + if not cfg.enable then + vim.notify( + 'go.nvim [AI]: AI is disabled. Set ai = { enable = true } in go.nvim setup to use GoAIChat', + vim.log.levels.WARN + ) + return + end + + local fargs = (type(opts) == 'table' and opts.fargs) or {} + local question = vim.trim(table.concat(fargs, ' ')) + + local code = nil + local lang = vim.bo.filetype or 'go' + local bufnr = vim.api.nvim_get_current_buf() + local func_name = nil + + -- 1. Visual selection: send selected code + if type(opts) == 'table' and opts.range and opts.range == 2 then + local sel_lines = vim.api.nvim_buf_get_lines(0, opts.line1 - 1, opts.line2, false) + code = table.concat(sel_lines, '\n') + + -- 2. Cursor inside a function: send the function text + elseif lang == 'go' then + local func_text, fname = get_enclosing_func(bufnr) + if func_text and func_text ~= '' then + code = func_text + func_name = fname + end + end + + -- Handle "create a commit summary" specially + local diff_text + if question:find('create a commit summary') then + local branch = question:find('main') and 'main' or 'master' + local code_text = vim.fn.system({ 'git', 'diff', '-U10', branch, '--', '*.go' }) + if not code_text or #code_text == 0 then + vim.notify('No code to commit', vim.log.levels.WARN) + return + end + diff_text = code_text + end + + --- Dispatch the question with code context and optional LSP references + local function dispatch(q) + if q == '' then + vim.notify('[GoAIChat]: empty question', vim.log.levels.WARN) + return + end + local user_msg = build_chat_user_msg(q, code, lang) + vim.notify('[GoAIChat]: thinking …', vim.log.levels.INFO) + M.request(chat_system_prompt, user_msg, { max_tokens = 2000, temperature = 0.2 }, function(resp) + open_chat_float(resp, q:sub(1, 60)) + end) + end + + --- Dispatch with LSP reference context appended to code + local function dispatch_with_refs(q) + if not func_name or func_name == '' then + dispatch(q) + return + end + + -- Try to gather references from LSP for richer context + local row, col = unpack(vim.api.nvim_win_get_cursor(0)) + local mcp_ok, mcp_ctx = pcall(require, 'go.mcp.context') + if not mcp_ok or not mcp_ctx.get_symbol_context_via_lsp then + dispatch(q) + return + end + + vim.notify('[GoAIChat]: gathering references …', vim.log.levels.INFO) + mcp_ctx.get_symbol_context_via_lsp(bufnr, row - 1, col, function(ref_text) + vim.schedule(function() + if ref_text and ref_text ~= '' and not ref_text:match('^%(no ') then + code = code .. '\n\n--- References / Callers ---\n' .. ref_text + end + dispatch(q) + end) + end) + end + + if diff_text then + return dispatch(diff_text) + end + + if question ~= '' then + -- If we have function context, enrich with references + if func_name and func_name ~= '' and code and not code:find('create a commit') then + dispatch_with_refs(question) + else + dispatch(question) + end + else + -- Interactive prompt + local default_q = code and 'explain this code' or '' + vim.ui.input({ + prompt = 'GoAIChat> ', + default = default_q, + }, function(input) + if input and input ~= '' then + if func_name and func_name ~= '' then + dispatch_with_refs(input) + else + dispatch(input) + end + end + end) + end +end + +-- ─── Public request helper ───────── +--- @param opts table|nil Optional: { temperature, max_tokens } +--- @param callback function Called with the response text string +function M.request(sys_prompt, user_msg, opts, callback) + opts = opts or {} + local cfg = _GO_NVIM_CFG.ai or {} + if not cfg.enable then + vim.notify('[GoCodeReview]: AI is disabled. Set ai = { enable = true } in go.nvim setup', vim.log.levels.WARN) + return + end + local provider = cfg.provider or 'copilot' + + if provider == 'copilot' then + send_copilot_raw(sys_prompt, user_msg, opts, callback) + elseif provider == 'openai' then + send_openai_raw(sys_prompt, user_msg, opts, callback) + else + vim.notify('[GoCodeReview]: unknown provider "' .. provider .. '"', vim.log.levels.ERROR) + end +end + +M.code_review_system_prompt = code_review_system_prompt +M.code_review_system_prompt_short = code_review_system_prompt_short +M.diff_review_system_prompt = diff_review_system_prompt +M.diff_review_system_prompt_short = diff_review_system_prompt_short +M.chat_system_prompt = chat_system_prompt + +return M diff --git a/lua/go/commands.lua b/lua/go/commands.lua index 4d23d2211..9cab58d89 100644 --- a/lua/go/commands.lua +++ b/lua/go/commands.lua @@ -470,6 +470,9 @@ return { create_cmd('GoCmt', function(_) require('go.comment').gen() end) + create_cmd('GoCmtAI', function(_) + require('go.comment').gen_ai() + end) create_cmd('GoRename', function(_) require('go.rename').lsprename() end) @@ -612,5 +615,252 @@ return { end, { nargs = '*', }) + + create_cmd('GoGopls', function(opts) + local gopls = require('go.gopls') + local subcmd = opts.fargs[1] + if not subcmd then + vim.notify('Usage: GoGopls [json_args]', vim.log.levels.WARN) + return + end + if not gopls.cmds[subcmd] then + vim.notify('Unknown gopls subcommand: ' .. subcmd, vim.log.levels.WARN) + return + end + local arg = {} + if opts.fargs[2] then + local json_str = table.concat(opts.fargs, ' ', 2) + local ok, parsed = pcall(vim.json.decode, json_str) + if ok then + arg = parsed + else + -- treat remaining args as key=value pairs + for i = 2, #opts.fargs do + local k, v = opts.fargs[i]:match('^(.-)=(.+)$') + if k then + arg[k] = v + end + end + end + end + gopls.cmds[subcmd](arg) + end, { + complete = function(_, _, _) + return vim.tbl_keys(require('go.gopls').cmds) + end, + nargs = '+', + }) + + create_cmd('GoAI', function(opts) + require('go.ai').run(opts) + end, { nargs = '*', + complete = function(_, _, _) + return { + '-f', -- full command catalog from go.txt + 'test this function', + 'test this file', + 'add tags to this struct', + 'run AI code review', + 'explain this code', + 'refactor this code', + 'check for bugs', + 'examine error handling', + 'simplify this', + 'what does this do', + 'suggest improvements', + 'check concurrency safety', + 'create a commit summary', + 'convert to idiomatic Go', + } + end, + range = true }) + + ---@param args table the opts table passed by nvim_create_user_command + ---@return table parsed options + local function parse_review_args(args) + local opts = {} + local fargs = args.fargs or {} + + -- Check for visual range selection + if args.range and args.range == 2 then + opts.visual = true + local start_line = args.line1 + local end_line = args.line2 + local bufnr = vim.api.nvim_get_current_buf() + local lines = vim.api.nvim_buf_get_lines(bufnr, start_line - 1, end_line, false) + opts.lines = table.concat(lines, '\n') + opts.start_line = start_line + opts.end_line = end_line + end + + local i = 1 + while i <= #fargs do + local arg = fargs[i] + if arg == '-d' or arg == '--diff' then + opts.diff = true + -- Next arg might be branch name + if fargs[i + 1] and not fargs[i + 1]:match('^%-') then + opts.branch = fargs[i + 1] + i = i + 1 + end + elseif arg == '-b' or arg == '--brief' then + opts.brief = true + elseif arg == '-f' or arg == '--full' then + opts.full = true + elseif arg == '-m' or arg == '--message' then + -- Collect everything after -m as the change description + local msg_parts = {} + for j = i + 1, #fargs do + table.insert(msg_parts, fargs[j]) + end + local raw = table.concat(msg_parts, ' ') + -- Convert literal \n sequences to real newlines + opts.message = raw:gsub('\\n', '\n') + break + end + i = i + 1 + end + + -- Default branch if diff mode but no branch specified + if opts.diff and not opts.branch then + opts.branch = 'master' + end + + return opts + end + + --- Open a floating buffer for multi-line message input using guihua textview. + --- Falls back to a plain floating window if guihua is not available. + --- Calls `on_submit(text)` with the buffer contents when the user submits. + local function open_message_input(on_submit) + local TextView = utils.load_plugin('guihua.lua', 'guihua.textview') + if TextView then + local width = math.min(80, vim.o.columns - 4) + local height = math.min(10, math.floor(vim.o.lines * 0.3)) + local win = TextView:new({ + loc = 'top_center', + rect = { height = height, width = width, pos_x = 0, pos_y = 4 }, + allow_edit = true, + enter = true, + ft = 'markdown', + title = ' Change description ( submit, q cancel) ', + title_pos = 'center', + data = { '' }, + }) + if not win or not win.buf then + vim.notify('[GoCodeReview]: failed to create input window', vim.log.levels.ERROR) + return + end + + -- Enable completion sources (Copilot, nvim-cmp) in the edit buffer + vim.api.nvim_set_option_value('buftype', '', { buf = win.buf }) + vim.api.nvim_set_option_value('filetype', 'markdown', { buf = win.buf }) + -- Force-attach copilot if available (bypasses filetype/buftype rejection) + local copilot_ok, copilot_cmd = pcall(require, 'copilot.command') + if copilot_ok and copilot_cmd.attach then + copilot_cmd.attach({ force = true, bufnr = win.buf }) + end + + local submitted = false + local function submit() + if submitted then return end + submitted = true + local lines = vim.api.nvim_buf_get_lines(win.buf, 0, -1, false) + win:close() + local text = vim.trim(table.concat(lines, '\n')) + on_submit(text) + end + + vim.keymap.set('n', '', submit, { buffer = win.buf, silent = true }) + vim.keymap.set('n', 'q', function() + if not submitted then + win:close() + end + end, { buffer = win.buf, silent = true }) + vim.cmd('startinsert') + else + vim.notify('[GoCodeReview]: guihua.lua not found, failed to create input window', vim.log.levels.WARN) + end + end + + create_cmd('GoCodeReview', function(args) + -- Use MCP-enhanced review when available + local go_cfg = require('go').config() or {} + + local function do_review(extra_opts) + if go_cfg.mcp and go_cfg.mcp.enable then + local opts = parse_review_args(args) + if extra_opts then opts = vim.tbl_extend('force', opts, extra_opts) end + require('go.mcp.review').review(opts) + else + if extra_opts and extra_opts.message then + -- Inject -m args so ai.code_review sees them + args.fargs = args.fargs or {} + table.insert(args.fargs, '-m') + table.insert(args.fargs, extra_opts.message) + end + require('go.ai').code_review(args) + end + end + + -- Check if -m is present with no text after it + local fargs = args.fargs or {} + local has_m, m_has_text = false, false + for idx, a in ipairs(fargs) do + if a == '-m' or a == '--message' then + has_m = true + m_has_text = (fargs[idx + 1] ~= nil and not fargs[idx + 1]:match('^%-')) + break + end + end + + if has_m and not m_has_text then + -- Open interactive buffer for multi-line input + open_message_input(function(text) + if text == '' then + vim.notify('[GoCodeReview]: cancelled (empty message)', vim.log.levels.INFO) + return + end + do_review({ message = text }) + end) + else + do_review() + end + end, { + nargs = '*', + range = true, + complete = function(_, _, _) + return { '-d', '--diff', '-b', '--brief', '-f', '--full', '-m', '--message' } + end, + }) + + create_cmd('GoDocAI', function(opts) + require('go.godoc').run_ai(opts) + end, { + nargs = '*', + complete = function(a, l) + return package.loaded.go.doc_complete(a, l) + end, + }) + create_cmd('GoAIChat', function(opts) + require('go.ai').chat(opts) + end, { + nargs = '*', + range = true, + complete = function(_, _, _) + return { + 'explain this code', + 'refactor this code', + 'check for bugs', + 'examine error handling', + 'simplify this', + 'what does this do', + 'suggest improvements', + 'check concurrency safety', + 'create a commit summary', + 'convert to idiomatic Go', + } + end, + }) end, } diff --git a/lua/go/comment.lua b/lua/go/comment.lua index 36f93f184..0f9775398 100644 --- a/lua/go/comment.lua +++ b/lua/go/comment.lua @@ -85,4 +85,84 @@ comment.gen = function() return c end +local comment_system_prompt = [[You are a Go documentation expert. +Generate a Go doc comment for the given code declaration. + +Rules: +1. Follow Go documentation conventions (https://go.dev/doc/comment). +2. The comment must start with "// " where is the identifier name. +3. For package clauses, start with "// Package ". +4. Be concise but informative. Describe what it does, not how. +5. If the code has parameters, mention important ones only when their purpose is non-obvious. +6. Return ONLY the comment lines (each starting with "//"). No code, no markdown fences, no extra text. +7. If it's a multi-line comment, use multiple "// " lines. +8. Do not add a blank line between comment lines. +]] + +--- Get the declaration node and its source text at cursor +local function get_declaration_at_cursor() + local bufnr = api.nvim_get_current_buf() + + -- Try each node type in order + local getters = { + { fn = require('go.ts.go').get_package_node_at_pos, kind = 'package' }, + { fn = require('go.ts.go').get_func_method_node_at_pos, kind = 'function' }, + { fn = require('go.ts.go').get_struct_node_at_pos, kind = 'struct' }, + { fn = require('go.ts.go').get_interface_node_at_pos, kind = 'interface' }, + { fn = require('go.ts.go').get_type_node_at_pos, kind = 'type' }, + } + + for _, g in ipairs(getters) do + local ns = g.fn() + if ns and ns.declaring_node then + local source = vim.treesitter.get_node_text(ns.declaring_node, bufnr) + return ns, source, g.kind + end + end + return nil, nil, nil +end + +--- Generate doc comment using AI/Copilot +comment.gen_ai = function() + local ns, source, kind = get_declaration_at_cursor() + if not ns or not source then + vim.notify('go.nvim [AI Comment]: no Go declaration found at cursor', vim.log.levels.WARN) + return + end + + local file = vim.fn.expand('%:t') or '' + local user_msg = string.format('File: %s\nKind: %s\n\n```go\n%s\n```', file, kind, source) + + vim.notify('go.nvim [AI Comment]: generating …', vim.log.levels.INFO) + + require('go.ai').request(comment_system_prompt, user_msg, { max_tokens = 300 }, function(resp) + -- Strip markdown fences if present + resp = resp:gsub('^```%w*\n?', ''):gsub('\n?```$', '') + resp = vim.trim(resp) + + -- Split into lines and validate each starts with "//" + local lines = vim.split(resp, '\n', { plain = true }) + local comment_lines = {} + for _, line in ipairs(lines) do + line = vim.trim(line) + if line:match('^//') then + table.insert(comment_lines, line) + end + end + + if #comment_lines == 0 then + vim.notify('go.nvim [AI Comment]: LLM returned no valid comment lines', vim.log.levels.WARN) + return + end + + local row = ns.dim.s.r + -- Insert comment lines above the declaration + for i = #comment_lines, 1, -1 do + vim.fn.append(row - 1, comment_lines[i]) + end + -- Position cursor at the end of the first comment line + vim.fn.cursor(row, #comment_lines[1] + 1) + end) +end + return comment diff --git a/lua/go/godoc.lua b/lua/go/godoc.lua index e55927637..bd5b609a3 100644 --- a/lua/go/godoc.lua +++ b/lua/go/godoc.lua @@ -202,4 +202,200 @@ m.run = function(fargs) }), setup end + +-- ─── GoDocAI ───────────────────────────────────────────────────────────────── + +local doc_ai_system_prompt = [[You are a Go documentation expert. The user will give you a vague or partial query about a Go function, method, type, or package, along with source code and/or doc output that was found. + +Your task is to produce clear, comprehensive, human-readable documentation in Markdown format for the matched symbol(s). + +For each symbol found, include: +1. **Signature** — the full function/method/type signature in a Go code block +2. **Description** — a clear explanation of what it does, its purpose, and typical usage +3. **Parameters** — describe each parameter (if applicable) +4. **Return values** — describe each return value (if applicable) +5. **Example** — a short usage example in a Go code block (if helpful) +6. **Notes** — any caveats, common mistakes, or related functions worth mentioning + +If the source code includes existing comments/doc, incorporate and expand on them. +If multiple symbols match, document each one separately. +Keep the output concise but informative. Do NOT include any preamble like "Here is the documentation". +]] + +--- Try to find source code for a symbol using multiple strategies: +--- 1. `go doc -src` for an exact or partial match +--- 2. `go doc` (without -src) for doc-only output +--- 3. gopls workspace/symbol for fuzzy lookup, then read the source +--- Returns (source_text, symbol_label) or (nil, error_msg) +local function find_symbol_source(query, callback) + -- Strategy 1: try `go doc -all` first to get doc text + local doc_cmd = string.format('go doc -all %s 2>/dev/null', vim.fn.shellescape(query)) + local doc_text = vim.fn.system(doc_cmd) + local doc_ok = (vim.v.shell_error == 0 and doc_text and vim.trim(doc_text) ~= '') + + -- Strategy 2: try `go doc -src` to get source code + local src_cmd = string.format('go doc -src %s 2>/dev/null', vim.fn.shellescape(query)) + local src_text = vim.fn.system(src_cmd) + local src_ok = (vim.v.shell_error == 0 and src_text and vim.trim(src_text) ~= '') + + if doc_ok or src_ok then + local combined = '' + if doc_ok then + combined = combined .. '--- go doc output ---\n' .. vim.trim(doc_text) .. '\n\n' + end + if src_ok then + combined = combined .. '--- source code ---\n' .. vim.trim(src_text) + end + callback(combined, query) + return + end + + -- Strategy 3: use gopls workspace/symbol for fuzzy matching + local clients = vim.lsp.get_clients({ bufnr = 0, name = 'gopls' }) + if not clients or #clients == 0 then + callback(nil, 'no documentation found for "' .. query .. '" (go doc failed and gopls not available)') + return + end + + vim.lsp.buf_request(0, 'workspace/symbol', { query = query }, function(err, result) + if err or not result or #result == 0 then + callback(nil, 'no symbols found matching "' .. query .. '"') + return + end + + vim.schedule(function() + -- Collect up to 5 best matches + local matches = {} + for i, sym in ipairs(result) do + if i > 5 then + break + end + local loc = sym.location + if loc and loc.uri then + local fpath = vim.uri_to_fname(loc.uri) + local line = (loc.range and loc.range.start and loc.range.start.line) or 0 + table.insert(matches, { + name = sym.name, + container = sym.containerName or '', + filepath = fpath, + line = line, + }) + end + end + + if #matches == 0 then + callback(nil, 'no readable symbols found for "' .. query .. '"') + return + end + + -- Read source around each match + local parts = {} + for _, match in ipairs(matches) do + local ok_file, file_lines = pcall(function() + return vim.fn.readfile(match.filepath) + end) + if ok_file and file_lines then + local start_line = math.max(0, match.line - 2) + local end_line = math.min(#file_lines, match.line + 50) + local snippet = {} + for l = start_line + 1, end_line do + table.insert(snippet, file_lines[l]) + end + local label = match.name + if match.container ~= '' then + label = match.container .. '.' .. match.name + end + table.insert(parts, string.format( + '--- %s (from %s:%d) ---\n%s', + label, + vim.fn.fnamemodify(match.filepath, ':~:.'), + match.line + 1, + table.concat(snippet, '\n') + )) + end + end + + if #parts == 0 then + callback(nil, 'found symbols but could not read source for "' .. query .. '"') + return + end + + callback(table.concat(parts, '\n\n'), query) + end) + end) +end + +--- Entry point for :GoDocAI {query} +--- Finds the symbol using go doc / gopls, then generates rich AI documentation. +--- @param opts table Standard nvim command opts (fargs) +m.run_ai = function(opts) + local fargs = (type(opts) == 'table' and opts.fargs) or opts or {} + local query = vim.trim(table.concat(fargs, ' ')) + + if query == '' then + -- Fallback: use the word under cursor + query = vim.fn.expand('') + if not query or query == '' then + vim.notify('go.nvim [DocAI]: please provide a query or place cursor on a symbol', vim.log.levels.WARN) + return + end + end + + local cfg = _GO_NVIM_CFG.ai or {} + if not cfg.enable then + vim.notify( + 'go.nvim [DocAI]: AI is disabled. Set ai = { enable = true } in go.nvim setup to use GoDocAI', + vim.log.levels.WARN + ) + return + end + + vim.notify('go.nvim [DocAI]: searching for "' .. query .. '" …', vim.log.levels.INFO) + + find_symbol_source(query, function(source, label) + if not source then + vim.notify('go.nvim [DocAI]: ' .. (label or 'symbol not found'), vim.log.levels.WARN) + return + end + + local user_msg = string.format('Query: %s\n\n%s', label, source) + + vim.notify('go.nvim [DocAI]: generating documentation …', vim.log.levels.INFO) + + require('go.ai').request(doc_ai_system_prompt, user_msg, { max_tokens = 1500, temperature = 0 }, function(resp) + resp = vim.trim(resp) + if resp == '' then + vim.notify('go.nvim [DocAI]: AI returned empty response', vim.log.levels.WARN) + return + end + + -- Display in a floating window + local lines = vim.split(resp, '\n', { plain = true }) + local height = math.min(#lines + 2, math.floor(vim.api.nvim_get_option('lines') * 0.8)) + local width = math.min(80, math.floor(vim.api.nvim_get_option('columns') * 0.8)) + -- Find the longest line to set width + for _, line in ipairs(lines) do + if #line > width then + width = math.min(#line + 2, math.floor(vim.api.nvim_get_option('columns') * 0.9)) + end + end + + local float_config = { + close_events = { 'CursorMoved', 'CursorMovedI', 'BufHidden', 'InsertCharPre' }, + focusable = true, + border = 'rounded', + width = width, + height = height, + max_width = math.floor(vim.api.nvim_get_option('columns') * 0.9), + max_height = math.floor(vim.api.nvim_get_option('lines') * 0.9), + offset_x = (vim.api.nvim_get_option('columns') - width) / 2 - 1, + offset_y = math.max(3, (vim.api.nvim_get_option('lines') - height) / 2 - 1) - 1, + relative = 'editor', + title = 'GoDocAI: ' .. query, + } + vim.lsp.util.open_floating_preview(lines, 'markdown', float_config) + end) + end) +end + return m diff --git a/lua/go/gopls.lua b/lua/go/gopls.lua index d6c7ddaa1..92aef656e 100644 --- a/lua/go/gopls.lua +++ b/lua/go/gopls.lua @@ -7,6 +7,56 @@ local cmds = {} local has_nvim0_10 = vim.fn.has('nvim-0.10') == 1 local has_nvim0_11 = vim.fn.has('nvim-0.11') == 1 -- https://go.googlesource.com/tools/+/refs/heads/master/gopls/doc/commands.md +-- https://github.com/golang/tools/blob/master/gopls/internal/doc/api.json +-- https://github.com/golang/tools/blob/master/gopls/internal/protocol/command/command_gen.go +--[[ + AddDependency Command = "gopls.add_dependency" + AddImport Command = "gopls.add_import" + AddTelemetryCounters Command = "gopls.add_telemetry_counters" + AddTest Command = "gopls.add_test" + ApplyFix Command = "gopls.apply_fix" + Assembly Command = "gopls.assembly" + ChangeSignature Command = "gopls.change_signature" + CheckUpgrades Command = "gopls.check_upgrades" + ClientOpenURL Command = "gopls.client_open_url" + DiagnoseFiles Command = "gopls.diagnose_files" + Doc Command = "gopls.doc" + EditGoDirective Command = "gopls.edit_go_directive" + ExtractToNewFile Command = "gopls.extract_to_new_file" + FetchVulncheckResult Command = "gopls.fetch_vulncheck_result" + FreeSymbols Command = "gopls.free_symbols" + GCDetails Command = "gopls.gc_details" + Generate Command = "gopls.generate" + GoGetPackage Command = "gopls.go_get_package" + ListImports Command = "gopls.list_imports" + ListKnownPackages Command = "gopls.list_known_packages" + LSP Command = "gopls.lsp" + MaybePromptForTelemetry Command = "gopls.maybe_prompt_for_telemetry" + MemStats Command = "gopls.mem_stats" + ModifyTags Command = "gopls.modify_tags" + Modules Command = "gopls.modules" + MoveType Command = "gopls.move_type" + PackageSymbols Command = "gopls.package_symbols" + Packages Command = "gopls.packages" + RegenerateCgo Command = "gopls.regenerate_cgo" + RemoveDependency Command = "gopls.remove_dependency" + ResetGoModDiagnostics Command = "gopls.reset_go_mod_diagnostics" + RunGoWorkCommand Command = "gopls.run_go_work_command" + RunGovulncheck Command = "gopls.run_govulncheck" + RunTests Command = "gopls.run_tests" + ScanImports Command = "gopls.scan_imports" + SplitPackage Command = "gopls.split_package" + StartDebugging Command = "gopls.start_debugging" + StartProfile Command = "gopls.start_profile" + StopProfile Command = "gopls.stop_profile" + Tidy Command = "gopls.tidy" + UpdateGoSum Command = "gopls.update_go_sum" + UpgradeDependency Command = "gopls.upgrade_dependency" + Vendor Command = "gopls.vendor" + Views Command = "gopls.views" + Vulncheck Command = "gopls.vulncheck" + WorkspaceStats Command = "gopls.workspace_stats" +]] -- local gopls_cmds = { 'gopls.add_dependency', @@ -68,6 +118,7 @@ local gopls_with_edit = { 'gopls.check_upgrades', 'gopls.change_signature', } +--- check_for_error inspects LSP response for error entries and notifies the user. local function check_for_error(msg) if msg ~= nil and type(msg[1]) == 'table' then for k, v in pairs(msg[1]) do @@ -80,6 +131,8 @@ local function check_for_error(msg) end end +--- apply_changes executes a gopls workspace command that produces document edits +--- and applies them to the current buffer via vim.lsp.util.apply_workspace_edit. local function apply_changes(cmd, args) local bufnr = vim.api.nvim_get_current_buf() local clients = vim.lsp.get_clients({ bufnr = bufnr }) @@ -193,12 +246,15 @@ for _, gopls_cmd in ipairs(gopls_cmds) do end M.cmds = cmds +--- import adds a Go import path via the gopls.add_import command and reformats the file. M.import = function(path) cmds.add_import({ ImportPath = path, }, require('go.format').gofmt) end +--- change_signature invokes gopls.change_signature to remove a parameter +--- identified by the current visual selection or cursor position. M.change_signature = function() local gopls = vim.lsp.get_clients({ bufnr = 0, name = 'gopls' }) if not gopls then @@ -222,6 +278,7 @@ M.change_signature = function() cmds.change_signature(lsp_params) end +--- gc_details toggles the display of GC optimization details for the given file URI. M.gc_details = function(args) local uri = vim.uri_from_bufnr(0) if args ~= nil then @@ -234,6 +291,8 @@ M.gc_details = function(args) cmds.gc_details(lsp_params) end +--- list_imports returns the imports of the file at path (default: current file) +--- as a table keyed by import group with string entries like "name:path" or "path". M.list_imports = function(path) path = path or vim.fn.expand('%:p') local resp = cmds.list_imports({ @@ -257,6 +316,7 @@ M.list_imports = function(path) return result, resp end +--- list_pkgs returns the list of known packages from gopls for auto-completion. M.list_pkgs = function() local resp = cmds.list_known_packages() or {} @@ -270,15 +330,19 @@ M.list_pkgs = function() return pkgs end +--- package_symbols retrieves and renders the symbols for a given package. M.package_symbols = function(pkg, render) -- not sure how to add pkg info, leave it empty for now cmds.package_symbols({}, render) end +--- tidy runs the gopls.tidy command to tidy go.mod. M.tidy = function(args) cmds.tidy(args) end +--- doc opens the Go documentation for the symbol at cursor (or the given URI) +--- in the system's default browser. M.doc = function(args) local gopls = vim.lsp.get_clients({ bufnr = 0, name = 'gopls' }) if not gopls then @@ -319,6 +383,8 @@ M.doc = function(args) end -- check_for_upgrades({Modules = {'package'}}) +--- version returns the installed gopls version string (e.g. "0.16.1"). +--- The result is cached in stdpath('cache')/version.txt. function M.version() local cache_dir = vfn.stdpath('cache') local path = string.format('%s%sversion.txt', cache_dir, utils.sep()) @@ -367,6 +433,7 @@ function M.version() return version end +--- get_current_gomod reads the module name from go.mod in the current directory. local get_current_gomod = function() local file = io.open('go.mod', 'r') if file == nil then @@ -383,6 +450,7 @@ local get_current_gomod = function() return mod_name end +--- get_build_flags returns the build tags/flags configured for the current project. local function get_build_flags() local get_build_tags = require('go.gotest').get_build_tags local tags = get_build_tags() @@ -432,6 +500,8 @@ M.semanticTokenModifiers = { struct = true, } +--- setups returns the default gopls LSP client configuration table, +--- including capabilities, filetypes, settings, codelenses, and inlay hints. M.setups = function() local diag_cfg = vim.diagnostic.config() or {} local update_in_insert = diag_cfg.update_in_insert or false diff --git a/lua/go/gotest.lua b/lua/go/gotest.lua index 756068c49..809428b78 100644 --- a/lua/go/gotest.lua +++ b/lua/go/gotest.lua @@ -30,8 +30,7 @@ local short_opts = 'a:cC:b:fFmn:pst:r:v' local bench_opts = { '-benchmem', '-cpuprofile', 'profile.out' } local is_windows = utils.is_windows() -local is_git_shell = is_windows - and (vim.fn.exists('$SHELL') and vim.fn.expand('$SHELL'):find('bash.exe') ~= nil) +local is_git_shell = is_windows and (vim.fn.exists('$SHELL') and vim.fn.expand('$SHELL'):find('bash.exe') ~= nil) M.efm = function() local indent = [[%\\%( %\\)]] local efm = [[%-G=== RUN %.%#]] @@ -359,7 +358,7 @@ M.test = function(...) run_test(fpath, args) end -M.test_suit = function(...) +M.test_suite = function(...) local args = { ... } log(args) @@ -514,9 +513,7 @@ M.test_func = function(...) if not p then -- require('nvim-treesitter.install').commands.TSInstallSync['run!']('go') vim.notify( - 'go treesitter parser not found for file ' - .. vim.fn.bufname() - .. ' please Run `:TSInstallSync go` ', + 'go treesitter parser not found for file ' .. vim.fn.bufname() .. ' please Run `:TSInstallSync go` ', vim.log.levels.WARN ) end @@ -561,9 +558,7 @@ M.get_test_cases = function() vim.notify('sed not found', vim.log.levels.WARN) return end - local cmd = [[cat ]] - .. fpath - .. [[| sed -n 's/func\s\+\(Test.*\)(.*/\1/p' | xargs | sed 's/ /\\|/g']] + local cmd = [[cat ]] .. fpath .. [[| sed -n 's/func\s\+\(Test.*\)(.*/\1/p' | xargs | sed 's/ /\\|/g']] local tests_results = vfn.systemlist(cmd) if vim.v.shell_error ~= 0 then utils.warn('go test failed' .. cmd .. vim.inspect(tests_results)) diff --git a/lua/go/health.lua b/lua/go/health.lua index 634658e14..97d84162a 100644 --- a/lua/go/health.lua +++ b/lua/go/health.lua @@ -256,6 +256,46 @@ local function lsp_check() end end +local function ai_check() + start('AI (GoAI / GoCmtAI)') + local cfg = _GO_NVIM_CFG.ai or {} + if not cfg.enable then + info('AI is disabled (ai.enable = false)') + return + end + ok('AI is enabled') + + local provider = cfg.provider or 'copilot' + info('Provider: ' .. provider) + + if provider == 'copilot' then + local paths = { + vim.fn.expand('~/.config/github-copilot/hosts.json'), + vim.fn.expand('~/.config/github-copilot/apps.json'), + } + local found = false + for _, path in ipairs(paths) do + if vfn.filereadable(path) == 1 then + found = true + ok('Copilot token file found: ' .. path) + break + end + end + if not found then + error('Copilot token file not found. Run :Copilot auth to authenticate') + end + elseif provider == 'openai' then + local env_name = cfg.api_key_env or 'OPENAI_API_KEY' + if os.getenv(env_name) then + ok('$' .. env_name .. ' is set') + else + error('$' .. env_name .. ' is not set') + end + else + warn('Unknown AI provider: ' .. provider) + end +end + function M.check() if vim.fn.has('nvim-0.10') == 0 then warn('Suggested neovim version 0.10 or higher') @@ -265,6 +305,7 @@ function M.check() lsp_check() plugin_check() env_check() + ai_check() end return M diff --git a/lua/go/mcp/client.lua b/lua/go/mcp/client.lua new file mode 100644 index 000000000..5ec81dd04 --- /dev/null +++ b/lua/go/mcp/client.lua @@ -0,0 +1,162 @@ +local M = {} +local uv = vim.uv +local log = require('go.utils').log +local json = vim.json + +---@class McpClient +---@field pending table +---@field next_id number +---@field buffer string +local McpClient = {} +McpClient.__index = McpClient + +function M.new(opts) + local self = setmetatable({}, McpClient) + self.pending = {} + self.next_id = 1 + self.buffer = '' + self.ready = false + self:_start(opts) + return self +end + +function McpClient:_start(opts) + local cmd = opts.cmd or { 'gopls', 'mcp' } + self.stdin = uv.new_pipe(false) + self.stdout = uv.new_pipe(false) + local stderr = uv.new_pipe(false) + + local args = {} + for i = 2, #cmd do + args[i - 1] = cmd[i] + end + + self.handle = uv.spawn(cmd[1], { + args = args, + stdio = { self.stdin, self.stdout, stderr }, + cwd = opts.root_dir or vim.fn.getcwd(), + }, function(code, signal) + log('gopls mcp exited', code, signal) + end) + + if not self.handle then + error('Failed to start gopls mcp: ' .. cmd[1]) + end + + -- Read stdout for JSON-RPC responses + self.stdout:read_start(function(err, data) + if err then + log('mcp read error:', err) + return + end + if data then + self:_on_data(data) + end + end) + + stderr:read_start(function(_, data) + if data then + log('mcp stderr:', data) + end + end) + + -- Send MCP initialize + self:request('initialize', { + protocolVersion = '2025-03-26', + capabilities = {}, + clientInfo = { name = 'go.nvim', version = '1.0.0' }, + }, function(err, result) + if not err then + self.ready = true + -- Send initialized notification + self:notify('notifications/initialized', {}) + log('MCP initialized, server:', result) + else + log('MCP init error:', err) + end + end) +end + +--- Parse MCP JSON-RPC messages (newline-delimited JSON) +function McpClient:_on_data(data) + self.buffer = self.buffer .. data + while true do + local nl = self.buffer:find('\n') + if not nl then + break + end + local line = self.buffer:sub(1, nl - 1) + self.buffer = self.buffer:sub(nl + 1) + + if #line > 0 then + local ok, msg = pcall(json.decode, line) + if ok and msg then + self:_handle_message(msg) + end + end + end +end + +function McpClient:_handle_message(msg) + if msg.id and self.pending[msg.id] then + local cb = self.pending[msg.id] + self.pending[msg.id] = nil + if msg.error then + cb(msg.error, nil) + else + cb(nil, msg.result) + end + end +end + +function McpClient:request(method, params, callback) + local id = self.next_id + self.next_id = self.next_id + 1 + self.pending[id] = callback + + local msg = json.encode({ + jsonrpc = '2.0', + id = id, + method = method, + params = params, + }) .. '\n' + + self.stdin:write(msg) +end + +function McpClient:notify(method, params) + local msg = json.encode({ + jsonrpc = '2.0', + method = method, + params = params, + }) .. '\n' + self.stdin:write(msg) +end + +--- Call an MCP tool +---@param tool_name string +---@param arguments table +---@param callback function(err, result) +function McpClient:call_tool(tool_name, arguments, callback) + self:request('tools/call', { + name = tool_name, + arguments = arguments, + }, callback) +end + +function McpClient:list_tools(callback) + self:request('tools/list', {}, callback) +end + +function McpClient:shutdown() + if self.handle then + self.stdin:close() + self.stdout:close() + if self.stderr then + self.stderr:close() + end + self.handle:kill('sigterm') + end +end + +return M diff --git a/lua/go/mcp/context.lua b/lua/go/mcp/context.lua new file mode 100644 index 000000000..61ba34728 --- /dev/null +++ b/lua/go/mcp/context.lua @@ -0,0 +1,431 @@ +local M = {} +local log = require('go.utils').log + +--- Parse a unified diff to extract changed file/line info +---@param diff_text string +---@return table[] list of {file, start_line, end_line} +function M.parse_diff_hunks(diff_text) + local hunks = {} + local current_file = nil + for line in diff_text:gmatch('[^\n]+') do + local file = line:match('^%+%+%+ b/(.+%.go)$') + if file then + current_file = file + end + local start, count = line:match('^@@ %-[%d,]+ %+(%d+),?(%d*) @@') + if start and current_file then + count = tonumber(count) or 1 + table.insert(hunks, { + file = current_file, + start_line = tonumber(start), + end_line = tonumber(start) + count - 1, + }) + end + end + return hunks +end + +--- Load a file into a buffer and ensure treesitter + LSP are ready +---@param filepath string absolute path +---@return number|nil bufnr +local function ensure_buffer(filepath) + if vim.fn.filereadable(filepath) ~= 1 then + return nil + end + local bufnr = vim.fn.bufadd(filepath) + vim.fn.bufload(bufnr) + if vim.bo[bufnr].filetype == '' then + vim.bo[bufnr].filetype = 'go' + end + return bufnr +end + +--- Read a specific line from a file (1-indexed) +---@param filepath string absolute path +---@param lnum number 1-indexed line number +---@return string|nil +local function read_line(filepath, lnum) + -- Try from loaded buffer first + local bufnr = vim.fn.bufnr(filepath) + if bufnr ~= -1 and vim.api.nvim_buf_is_loaded(bufnr) then + local lines = vim.api.nvim_buf_get_lines(bufnr, lnum - 1, lnum, false) + if lines and lines[1] then + return vim.trim(lines[1]) + end + end + -- Fall back to reading from disk + local f = io.open(filepath, 'r') + if not f then + return nil + end + local i = 0 + for line in f:lines() do + i = i + 1 + if i == lnum then + f:close() + return vim.trim(line) + end + end + f:close() + return nil +end + +--- Check if a filename is a test file +---@param fname string +---@return boolean +local function is_test_file(fname) + return fname:match('_test%.go$') ~= nil +end + +--- Format a reference location, including line text for non-test files +---@param uri string LSP URI +---@param line number 0-indexed +---@return string +local function format_ref_location(uri, line) + local fpath = vim.uri_to_fname(uri) + local fname = vim.fn.fnamemodify(fpath, ':.') + local lnum = line + 1 + + if is_test_file(fname) then + return string.format('\t- %s:%d', fname, lnum) + end + + local text = read_line(fpath, lnum) + if text then + return string.format('\t- %s:%d `%s`', fname, lnum, text) + end + return string.format('\t- %s:%d', fname, lnum) +end + +--- Format a caller location, including line text for non-test files +---@param uri string LSP URI +---@param line number 0-indexed +---@param caller_name string +---@return string +local function format_caller_location(uri, line, caller_name) + local fpath = vim.uri_to_fname(uri) + local fname = vim.fn.fnamemodify(fpath, ':.') + local lnum = line + 1 + + if is_test_file(fname) then + return string.format('\t- %s:%d — %s()', fname, lnum, caller_name) + end + + local text = read_line(fpath, lnum) + if text then + return string.format('\t- %s:%d — %s() `%s`', fname, lnum, caller_name, text) + end + return string.format('\t- %s:%d — %s()', fname, lnum, caller_name) +end + +--- Use treesitter to find symbols (functions, types, methods) at given lines +---@param bufnr number +---@param start_line number (0-indexed) +---@param end_line number (0-indexed) +---@return table[] symbols [{name, kind, line, col}] +function M.find_symbols_in_range(bufnr, start_line, end_line) + local symbols = {} + local ok, parser = pcall(vim.treesitter.get_parser, bufnr, 'go') + if not ok or not parser then + return symbols + end + local trees = parser:parse() + if not trees or not trees[1] then + return symbols + end + local root = trees[1]:root() + + local query_text = [[ + (function_declaration name: (identifier) @func_name) @func + (method_declaration name: (field_identifier) @method_name) @method + (type_declaration (type_spec name: (type_identifier) @type_name)) @type + ]] + + local qok, query = pcall(vim.treesitter.query.parse, 'go', query_text) + if not qok then + return symbols + end + + for id, node, _ in query:iter_captures(root, bufnr, start_line, end_line + 1) do + local name = query.captures[id] + if name == 'func_name' or name == 'method_name' or name == 'type_name' then + local text = vim.treesitter.get_node_text(node, bufnr) + local row, col = node:start() + local kind = name:match('^(%w+)_') + table.insert(symbols, { name = text, kind = kind, line = row, col = col }) + end + end + return symbols +end + +--- Get symbol context using the EXISTING running LSP client (no second gopls) +---@param bufnr number +---@param line number 0-indexed +---@param col number 0-indexed +---@param callback function(context_string) +function M.get_symbol_context_via_lsp(bufnr, line, col, callback) + local results = {} + local pending = 3 + + -- Find an attached gopls client for this buffer + local clients = vim.lsp.get_clients({ bufnr = bufnr, name = 'gopls' }) + if #clients == 0 then + clients = vim.lsp.get_clients({ name = 'gopls' }) + if #clients == 0 then + callback('(no gopls LSP client available)') + return + end + vim.lsp.buf_attach_client(bufnr, clients[1].id) + end + + local function make_params() + return { + textDocument = { uri = vim.uri_from_bufnr(bufnr) }, + position = { line = line, character = col }, + } + end + + local function check_done() + pending = pending - 1 + if pending <= 0 then + callback(table.concat(results, '\n')) + end + end + + -- 1. References + local ref_params = make_params() + ref_params.context = { includeDeclaration = false } + vim.lsp.buf_request(bufnr, 'textDocument/references', ref_params, function(err, result) + if not err and result and #result > 0 then + -- Partition into non-test (high value) and test refs, dedup both + local non_test_refs = {} + local test_refs = {} + local seen = {} + for _, ref in ipairs(result) do + local fpath = vim.uri_to_fname(ref.uri) + local fname = vim.fn.fnamemodify(fpath, ':.') + local key = string.format('- %s:%d', fname, ref.range.start.line + 1) + if not seen[key] then + seen[key] = true + if is_test_file(fname) then + table.insert(test_refs, ref) + else + table.insert(non_test_refs, ref) + end + end + end + -- Show non-test refs first, then fill remaining slots with test refs + local max_refs = 15 + local refs = {} + for _, ref in ipairs(non_test_refs) do + table.insert(refs, format_ref_location(ref.uri, ref.range.start.line)) + if #refs >= max_refs then + break + end + end + local remaining = max_refs - #refs + for idx, ref in ipairs(test_refs) do + if idx > remaining then + local skipped = #test_refs - remaining + table.insert(refs, string.format(' ... and %d more test refs', skipped)) + break + end + table.insert(refs, format_ref_location(ref.uri, ref.range.start.line)) + end + table.insert(results, '\n* References (' .. #result .. '):\n' .. table.concat(refs, '\n')) + end + check_done() + end) + + -- 2. Incoming calls (who calls this?) + vim.lsp.buf_request(bufnr, 'textDocument/prepareCallHierarchy', make_params(), function(err, result) + if err or not result or #result == 0 then + check_done() + return + end + vim.lsp.buf_request(bufnr, 'callHierarchy/incomingCalls', { item = result[1] }, function(err2, calls) + if not err2 and calls and #calls > 0 then + -- Partition callers: non-test first + local non_test_calls = {} + local test_calls = {} + for _, call in ipairs(calls) do + local fname = vim.fn.fnamemodify(vim.uri_to_fname(call.from.uri), ':.') + if is_test_file(fname) then + table.insert(test_calls, call) + else + table.insert(non_test_calls, call) + end + end + local max_callers = 15 + local callers = {} + for _, call in ipairs(non_test_calls) do + table.insert(callers, format_caller_location( + call.from.uri, call.from.range.start.line, call.from.name + )) + if #callers >= max_callers then + break + end + end + local remaining = max_callers - #callers + for idx, call in ipairs(test_calls) do + if idx > remaining then + local skipped = #test_calls - remaining + table.insert(callers, string.format(' ... and %d more test callers', skipped)) + break + end + table.insert(callers, format_caller_location( + call.from.uri, call.from.range.start.line, call.from.name + )) + end + table.insert(results, '\n* Callers (' .. #calls .. '):\n' .. table.concat(callers, '\n')) + end + check_done() + end) + end) + + -- 3. Implementations (for interfaces/methods) + vim.lsp.buf_request(bufnr, 'textDocument/implementation', make_params(), function(err, result) + if not err and result and #result > 0 then + local impls = {} + for _, impl in ipairs(result) do + table.insert(impls, format_ref_location(impl.uri, impl.range.start.line)) + if #impls >= 15 then + break + end + end + table.insert(results, '\n* Implementations (' .. #result .. '):\n' .. table.concat(impls, '\n')) + end + check_done() + end) +end + +-- Backward-compatible alias +M.get_symbol_context = M.get_symbol_context_via_lsp + +--- Gather semantic context for all changed symbols across diff files +--- Uses the EXISTING gopls LSP client instead of a separate MCP process +---@param diff_text string +---@param callback function(semantic_context: string) +function M.gather_diff_context(diff_text, callback) + local hunks = M.parse_diff_hunks(diff_text) + + if #hunks == 0 then + callback('(no changed Go symbols detected)') + return + end + + local by_file = {} + for _, hunk in ipairs(hunks) do + by_file[hunk.file] = by_file[hunk.file] or {} + table.insert(by_file[hunk.file], hunk) + end + + local all_context = {} + local files_pending = vim.tbl_count(by_file) + + if files_pending == 0 then + callback('(no changed Go files)') + return + end + + for file, file_hunks in pairs(by_file) do + local abs_path = vim.fn.getcwd() .. '/' .. file + local bufnr = ensure_buffer(abs_path) + + if not bufnr then + log('mcp/context: cannot load file', abs_path) + files_pending = files_pending - 1 + if files_pending == 0 then + callback(table.concat(all_context, '\n\n')) + end + goto continue + end + + local symbols = {} + for _, hunk in ipairs(file_hunks) do + local found = M.find_symbols_in_range(bufnr, hunk.start_line - 1, hunk.end_line - 1) + for _, sym in ipairs(found) do + symbols[sym.name] = sym + end + end + + local symbol_list = vim.tbl_values(symbols) + if #symbol_list == 0 then + table.insert(all_context, + string.format('## File: %s\n(changed lines do not contain function/type declarations)', file)) + files_pending = files_pending - 1 + if files_pending == 0 then + callback(table.concat(all_context, '\n\n')) + end + else + local syms_pending = #symbol_list + for _, sym in ipairs(symbol_list) do + local header = string.format('### Symbol: `%s` (%s) in %s:%d', sym.name, sym.kind, file, sym.line + 1) + + M.get_symbol_context_via_lsp(bufnr, sym.line, sym.col, function(ctx) + if ctx and #ctx > 0 then + table.insert(all_context, header .. '\n' .. ctx) + else + table.insert(all_context, header .. '\n(no callers/references found — possibly unexported or unused)') + end + + syms_pending = syms_pending - 1 + if syms_pending == 0 then + files_pending = files_pending - 1 + if files_pending == 0 then + callback(table.concat(all_context, '\n\n')) + end + end + end) + end + end + + ::continue:: + end +end + +--- Gather context for all symbols in a single buffer (non-diff mode) +---@param bufnr number +---@param callback function(semantic_context: string) +function M.gather_buffer_context(bufnr, callback) + local line_count = vim.api.nvim_buf_line_count(bufnr) + local symbols = M.find_symbols_in_range(bufnr, 0, line_count - 1) + local file = vim.fn.fnamemodify(vim.api.nvim_buf_get_name(bufnr), ':.') + + if #symbols == 0 then + callback('(no function/type declarations found in buffer)') + return + end + + if #symbols > 15 then + log('mcp/context: truncating symbols from', #symbols, 'to 15') + local truncated = {} + for i = 1, 15 do + truncated[i] = symbols[i] + end + symbols = truncated + end + + local all_ctx = {} + local pending = #symbols + + for _, sym in ipairs(symbols) do + local header = string.format('* Symbol: `%s` (%s) in %s:%d', sym.name, sym.kind, file, sym.line + 1) + + M.get_symbol_context_via_lsp(bufnr, sym.line, sym.col, function(ctx) + if ctx and #ctx > 0 then + table.insert(all_ctx, header .. '\n' .. ctx) + else + table.insert(all_ctx, header .. '\n(no callers/references found)') + end + + pending = pending - 1 + if pending == 0 then + callback(table.concat(all_ctx, '\n\n')) + end + end) + end +end + +return M + diff --git a/lua/go/mcp/init.lua b/lua/go/mcp/init.lua new file mode 100644 index 000000000..d6b98fab6 --- /dev/null +++ b/lua/go/mcp/init.lua @@ -0,0 +1,32 @@ +local M = { + _client = nil, + _config = { + gopls_cmd = { 'gopls', 'mcp' }, + root_dir = nil, + }, +} +local client_mod = require('go.mcp.client') + +function M.setup(opts) + opts = opts or {} + M._config = { + gopls_cmd = opts.gopls_cmd or { 'gopls', 'mcp' }, + root_dir = opts.root_dir, + } +end + +function M.get_client() + if not M._client then + M._client = client_mod.new(M._config) + end + return M._client +end + +function M.shutdown() + if M._client then + M._client:shutdown() + M._client = nil + end +end + +return M diff --git a/lua/go/mcp/review.lua b/lua/go/mcp/review.lua new file mode 100644 index 000000000..387033e88 --- /dev/null +++ b/lua/go/mcp/review.lua @@ -0,0 +1,214 @@ +local M = {} +local mcp_context = require('go.mcp.context') +local log = require('go.utils').log +local prompts = require('go.prompts') + +-- JSON output format shared by all MCP review prompts +local json_output_format = [[ + +Output as JSON array of objects with fields: +- "file": filename +- "line": line number (integer) +- "col": column number (integer, default 1) +- "severity": "error" | "warning" | "info" +- "violation": short violation label (e.g., "Non-idiomatic naming", "Slice memory leak") +- "principle": one of "[Effective Go]", "[100 Go Mistakes]", "[Google Style]" +- "message": description of the issue +- "refactor": brief refactored code or suggestion (optional, empty string if not applicable) + +If no issues found, return: [] +]] + +-- stylua: ignore start +local mcp_code_review_system = + [[You are an experienced Go code reviewer with access to semantic context from gopls. +Your task is to review Go source code for correctness, readability, performance, best practices, and style. +Use the semantic context to assess impact on callers, interfaces, and downstream consumers. + +When reviewing, reason step-by-step about each aspect of the code before concluding. Be polite, professional, and constructive. +]] + .. prompts.review_guidelines() .. json_output_format + +local mcp_code_review_system_short = + [[You are an experienced Go code reviewer with semantic context from gopls. +Focus on: bugs, correctness, error handling, concurrency issues, resource leaks, and breaking changes to callers/interfaces. +]] .. json_output_format + +local mcp_diff_review_system = + [[You are an experienced Go code reviewer with access to semantic context from gopls. +You are reviewing a unified diff of Go source code changes. Focus on the changed lines. +Use the semantic context to assess impact on callers, interfaces, and downstream consumers. + +When reviewing, reason step-by-step about each aspect of the code before concluding. Be polite, professional, and constructive. +]] + .. prompts.review_guidelines() .. json_output_format + +local mcp_diff_review_system_short = + [[You are an experienced Go code reviewer with semantic context from gopls. +Review the unified diff for bugs, correctness, error handling, concurrency, and breaking changes in the changed lines only. +]] .. json_output_format +-- stylua: ignore end + +--- Build the enriched user prompt with diff/code + semantic context +---@param code_text string the code or diff text +---@param semantic_context string gathered MCP context +---@param opts table optional; opts.diff=true for diff mode +---@return string the user prompt for AI +local function build_enriched_prompt(code_text, semantic_context, opts) + local parts = {} + if opts and opts.message and opts.message ~= '' then + table.insert(parts, '## Change Description\n' .. opts.message .. '\n') + end + if opts and opts.diff then + table.insert(parts, '## Git Diff\n```diff\n' .. code_text .. '\n```') + else + table.insert(parts, '## Source Code\n```go\n' .. code_text .. '\n```') + end + table.insert(parts, '\n## Semantic Context for Changed Symbols\n' .. semantic_context) + if not opts or not opts.brief then + table.insert(parts, '\nUse the semantic context to assess the full impact:') + table.insert(parts, '- Are callers affected by signature changes?') + table.insert(parts, '- Do interface contracts still hold?') + table.insert(parts, '- Are there downstream consumers that might break?') + table.insert(parts, '- Are error handling patterns consistent with existing callers?') + end + return table.concat(parts, '\n') +end + +--- Enhanced code review with semantic context from the running gopls LSP +---@param opts table {diff=bool, branch=string, visual=bool, lines=string} +function M.review(opts) + opts = opts or {} + local ai = require('go.ai') + + -- Get the diff or file content (reuse existing logic) + local code_text + if opts.diff then + -- Determine base branch for diff: + -- 1) honor explicit opts.branch + -- 2) otherwise, use ai.detect_default_branch() if available + -- 3) fall back to legacy master/main behavior for compatibility + local branch = opts.branch + if not branch or branch == '' then + if ai.detect_default_branch then + branch = ai.detect_default_branch() + end + end + + if not branch or branch == '' then + branch = 'master' + end + + code_text = vim.fn.system({ 'git', 'diff', '-U10', branch, '--', '*.go' }) + + -- If diffing against the detected/explicit branch fails, try legacy fallbacks. + if vim.v.shell_error ~= 0 and branch ~= 'master' and branch ~= 'main' then + code_text = vim.fn.system({ 'git', 'diff', '-U10', 'master', '--', '*.go' }) + if vim.v.shell_error ~= 0 then + code_text = vim.fn.system({ 'git', 'diff', '-U10', 'main', '--', '*.go' }) + end + end + elseif opts.visual and opts.lines then + code_text = opts.lines + else + local bufnr = vim.api.nvim_get_current_buf() + code_text = table.concat(vim.api.nvim_buf_get_lines(bufnr, 0, -1, false), '\n') + end + + if not code_text or #code_text == 0 then + vim.notify('No code to review', vim.log.levels.WARN) + return + end + + vim.notify('[GoReview]: gathering semantic context from gopls...', vim.log.levels.INFO) + + local brief = opts.brief or false + + local function send_review(sys_prompt, semantic_ctx) + local prompt = build_enriched_prompt(code_text, semantic_ctx, opts) + ai.request(sys_prompt, prompt, { max_tokens = 4096, temperature = 0 }, function(response) + M._handle_review_response(response) + end) + end + + if opts.diff then + mcp_context.gather_diff_context(code_text, function(semantic_ctx) + vim.schedule(function() + vim.notify('go.nvim [Review]: semantic context ready. Sending to AI...', vim.log.levels.INFO) + local sys = brief and mcp_diff_review_system_short or mcp_diff_review_system + send_review(sys, semantic_ctx) + end) + end) + else + local bufnr = vim.api.nvim_get_current_buf() + mcp_context.gather_buffer_context(bufnr, function(semantic_ctx) + vim.schedule(function() + vim.notify('go.nvim [Review]: semantic context ready. Sending to AI...', vim.log.levels.INFO) + local sys = brief and mcp_code_review_system_short or mcp_code_review_system + send_review(sys, semantic_ctx) + end) + end) + end +end + +--- Parse AI response and populate quickfix list +function M._handle_review_response(response) + vim.schedule(function() + if not response or #response == 0 then + vim.notify('No review findings', vim.log.levels.INFO) + return + end + + -- Strip markdown code fences if present + local cleaned = response:gsub('^```json%s*', ''):gsub('%s*```$', '') + local ok, findings = pcall(vim.json.decode, cleaned) + if not ok or type(findings) ~= 'table' then + vim.notify('Failed to parse AI review response', vim.log.levels.ERROR) + log('Raw response:', response) + return + end + + local qf_items = {} + local severity_map = { + error = 'E', + warning = 'W', + info = 'I', + } + log(findings) + for _, item in ipairs(findings) do + -- Build enriched message with violation/principle/refactor when available + + local parts = {} + if item.violation and item.violation ~= '' then + table.insert(parts, '[' .. item.violation .. ']') + end + if item.principle and item.principle ~= '' then + table.insert(parts, item.principle) + end + if item.message and item.message ~= '' then + table.insert(parts, item.message) + end + if item.refactor and item.refactor ~= '' then + table.insert(parts, 'Refactor: ' .. item.refactor) + end + local text = table.concat(parts, ' ') + + table.insert(qf_items, { + filename = item.file or vim.fn.expand('%'), + lnum = tonumber(item.line) or 1, + col = tonumber(item.col) or 1, + text = text, + type = severity_map[item.severity] or 'W', + }) + end + + vim.fn.setqflist(qf_items, 'r') + vim.fn.setqflist({}, 'a', { title = 'GoCodeReview (MCP-enhanced)' }) + if #qf_items > 0 then + vim.cmd('copen') + end + vim.notify(string.format('Code review: %d findings', #qf_items), vim.log.levels.INFO) + end) +end + +return M diff --git a/lua/go/prompts.lua b/lua/go/prompts.lua new file mode 100644 index 000000000..c14fe3e9f --- /dev/null +++ b/lua/go/prompts.lua @@ -0,0 +1,100 @@ +-- Shared prompt building blocks for Go code review (used by ai.lua and mcp/review.lua) + +local M = {} + +-- stylua: ignore start + +M.audit_categories = [[ +# Audit Categories + +1. Code Organization: Misaligned project structure, init function abuse, or getter/setter overkill. +2. Data Types: Octal literals, integer overflows, floating-point inaccuracies, and slice/map header confusion. +3. Control Structures: Range loop pointer copies, using break in switch inside for loops, and map iteration non-determinism. +4. String Handling: Inefficient concatenation, len() vs. rune count, and substring memory leaks. +5. Functions & Methods: Pointer vs. value receivers, named result parameters, and returning nil interfaces. +6. Error Management: Panic/recover abuse, ignoring errors, and failing to wrap errors with %w. +7. Concurrency: Goroutine leaks, context misuse, data races, and sync vs. channel trade-offs. +8. Standard Library: http body closing, json marshaling pitfalls, and time.After leaks. +9. Testing: Table-driven test errors, race conditions in tests, and external dependency mocking. +10. Optimizations: CPU cache misalignment, false sharing, and stack vs. heap escape analysis.]] + +M.review_dimensions = [[ +# Go-Specific Review Dimensions + +## Formatting & Naming (Effective Go / Google Style) +- Indentation/Formatting: Check for non-standard layouts (assume gofmt standards). +- Naming: Enforce short, pithy names for local variables (e.g., r for reader) and MixedCaps/Exported naming conventions. +- Interface Names: Ensure one-method interfaces end in an "er" suffix (e.g., Reader, Writer). +- Function/Method Naming: Avoid repeating package name (e.g., yamlconfig.Parse not yamlconfig.ParseYAMLConfig), receiver type, parameter names, or return types in the function name. +- No Get Prefix: Functions returning values should use noun-like names without "Get" prefix (e.g., JobName not GetJobName). Functions doing work should use verb-like names. +- Util Packages: Flag packages named "util", "helper", "common" — names should describe what the package provides. + +## Initialization & Control (The "Go Way") +- Redeclaring vs. Reassigning: Identify where := is used correctly vs. where it creates shadowing bugs. Flag shadowing of variables in inner scopes (especially context, error) that silently creates new variables instead of updating the outer one. +- Do not shadow standard package names (e.g., using "url" as a variable name blocks net/url). +- The Switch Power: Look for complex if-else chains that should be simplified into Go's powerful switch (which handles multiple expressions and comparisons). +- Allocation: Differentiate between new (zeroed memory pointer) and make (initialized slice/map/chan). +- Prefer := for non-zero initialization, var for zero-value declarations. +- Signal Boosting: Flag easy-to-miss "err == nil" checks (positive error checks) — these should have a clarifying comment. + +## Data Integrity & Memory (100 Go Mistakes) +- Slice/Map Safety: Check for sub-slice memory leaks and map capacity issues. +- Conversions: Ensure string-to-slice conversions are necessary and efficient. +- Backing Arrays: Flag cases where multiple slices share a backing array unintentionally. +- Size Hints: For performance-sensitive code, check if make() should have capacity hints for slices/maps when the size is known. +- Channel Direction: Ensure channel parameters specify direction (<-chan or chan<-) where possible. +- Map Initialization: Flag writes to nil maps (maps must be initialized with make before mutation, though reads are safe). + +## Concurrency & Errors +- Communication: "Do not communicate by sharing memory; instead, share memory by communicating." Flag excessive Mutex use where Channels would be cleaner. +- Only sender can close a channel: Flag cases where multiple goroutines might close the same channel, which can cause panics. +- Error Handling: Check for the "Happy Path" (return early on errors to keep the successful logic left-aligned). +- Error Structure: Flag string-matching on error messages — use sentinel errors, errors.Is, or errors.As instead. +- Error Wrapping: Ensure %w is used (not %v) when callers need to inspect wrapped errors. Place %w at the end of the format string. Avoid redundant annotations (e.g., "failed: %v" adds nothing — just return err). Do not duplicate information the underlying error already provides. +- Panic/Recover: Ensure panic is only used for truly unrecoverable setup errors or API misuse, not for flow control. Panics must never escape package boundaries in libraries — use deferred recover at public API boundaries. +- Do not call log.Fatal or t.Fatal from goroutines other than the main test goroutine. +- Handle error cases first (left-aligned), then the successful path. Avoid deep nesting of if statements for the happy path. Reduce `if err != nil` nesting by returning early. +- Errors should only be handled once — avoid patterns where errors are checked, annotated, and returned in multiple layers. +- Use traceID or context values for cross-cutting concerns instead of passing through multiple layers of error annotations. + +## Documentation & API Design (Google Style) +- Context conventions: Do not restate that cancelling ctx stops the function (it is implied). Document only non-obvious context behavior. +- Cleanup: Exported constructors/functions that acquire resources must document how to release them (e.g., "Call Stop to release resources when done"). +- Concurrency safety: Document non-obvious concurrency properties. Read-only operations are assumed safe; mutating operations are assumed unsafe. Document exceptions. +- Error documentation: Document significant sentinel errors and error types returned by functions, including whether they are pointer receivers. +- Function argument lists: Flag functions with too many parameters. Recommend option structs or variadic options pattern for complex configuration. + +## Testing (Google Style) +- Leave testing to the Test function: Flag assertion helper libraries — prefer returning errors or using cmp.Diff with clear failure messages in the Test function itself. +- Table-driven tests: Use field names in struct literals. Keep setup scoped to tests that need it (no global init for test data). +- t.Fatal usage: Use t.Fatal only for setup failures. In table-driven subtests, use t.Fatal inside t.Run; outside subtests, use t.Error + continue. +- Do not call t.Fatal from separate goroutines — use t.Error and return instead. +- Test doubles: Follow naming conventions (package suffixed with "test", types named by behavior like AlwaysCharges). +- Mocking: Prefer interface-based design for testability. For external dependencies, use in-memory implementations or test servers instead of complex mocking frameworks. +- Logging: Use t.Log for test logs, not global loggers. Test logs are only shown on failure or with verbose flag. +- Avoid shared state between tests. Each test should be independent and repeatable. + +## Global State & Dependencies +- Flag package-level mutable state (global vars, registries, singletons). Prefer instance-based APIs with explicit dependency passing. +- Flag service locator patterns and thick-client singletons. + +## String Handling (Google Style) +- Prefer "+" for simple concatenation, fmt.Sprintf for formatting, strings.Builder for piecemeal construction. +- Use backticks for constant multi-line strings.]] + +M.output_critique_format = [[ +# Output Instructions + +For every critique, provide: +1. The Violation (e.g., "Non-idiomatic naming" or "Slice memory leak"). +2. The Principle: Cite if it is an [Effective Go] rule, a [100 Go Mistakes] pitfall, or a [Google Style] convention. +3. A brief refactored code suggestion where applicable.]] + +-- stylua: ignore end + +--- Build the full review guidelines block (audit + dimensions + output instructions) +function M.review_guidelines() + return M.audit_categories .. '\n' .. M.review_dimensions .. '\n' .. M.output_critique_format +end + +return M