├── LICENSE ├── README.md ├── demo.gif ├── doc ├── cmp_go_deep.txt └── tags ├── lua └── cmp_go_deep │ ├── db.lua │ ├── gopls_requests.lua │ ├── init.lua │ ├── test.lua │ ├── treesitter_implementations.lua │ └── utils.lua └── plugin └── cmp_go_deep.lua /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Samiul Islam 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # cmp-go-deep 2 | 3 | A Go ```deep-completion``` source for [nvim-cmp](https://github.com/hrsh7th/nvim-cmp) / [blink.cmp](https://github.com/Saghen/blink.cmp), that works alongside [cmp-nvim-lsp](https://github.com/hrsh7th/cmp-nvim-lsp) / [blink.cmp](https://github.com/Saghen/blink.cmp)'s LSP source and provides completion suggestions for "UNIMPORTED LOCAL, INTERNAL, AND VENDORED PACKAGES ONLY". 4 | 5 | #### Why? 6 | 7 | At the time of writing, the GoLang Language Server (```gopls@v0.18.1```) doesn't seem to support deep completions for unimported packages. For example, with deep completion enabled, typing ```'cha'``` could suggest ```'rand.NewChaCha8()'``` as a possible completion option - but that is not the case no matter how high the completion budget is set for ```gopls```. 8 | 9 | 10 | #### How? 11 | 12 | 13 | Query ```gopls's``` ```workspace/symbol``` endpoint, cache the results using ```sqlite```, convert the resulting ```SymbolInformation``` into ```completionItemKinds```, filter the results to only include the ones that are unimported, then finally feed them back into ```nvim-cmp``` / ```blink.cmp``` 14 | 15 | --- 16 | ⚠️ it might take a while for the packages to be indexed by gopls in huge codebases 17 | #### Demo 18 | 19 | * Note: Due to how gopls indexes packages, completions for standard library packages are not available until at least one of them is manually imported. 20 |

21 | demo 22 |

23 | 24 | --- 25 | ## Requirements 26 | - [nvim-cmp](https://github.com/hrsh7th/nvim-cmp) OR [blink.cmp](https://github.com/saghen/blink.cmp) 27 | - [sqlite.lua](https://github.com/kkharji/sqlite.lua) 28 | 29 | ## Setup 30 | #### Lazy.nvim 31 | ##### - nvim-cmp 32 | ```lua 33 | { 34 | "hrsh7th/nvim-cmp", 35 | dependencies = { 36 | { "samiulsami/cmp-go-deep", dependencies = { "kkharji/sqlite.lua" } }, 37 | }, 38 | ... 39 | require("cmp").setup({ 40 | sources = {{ 41 | name = "go_deep", 42 | keyword_length = 3, 43 | max_item_count = 5, 44 | ---@module "cmp_go_deep" 45 | ---@type cmp_go_deep.Options 46 | option = { 47 | -- See below for configuration options 48 | }, 49 | }}, 50 | }) 51 | } 52 | ``` 53 | ##### - blink.cmp (requires saghen/blink.compat) 54 | ```lua 55 | { 56 | "saghen/blink.cmp", 57 | dependencies = { 58 | { "samiulsami/cmp-go-deep", dependencies = { "kkharji/sqlite.lua" } }, 59 | { "saghen/blink.compat" }, 60 | }, 61 | opts = { 62 | sources = { 63 | default = { 64 | "go_deep", 65 | }, 66 | providers = { 67 | go_deep = { 68 | name = "go_deep", 69 | module = "blink.compat.source", 70 | min_keyword_length = 3, 71 | max_items = 5, 72 | ---@module "cmp_go_deep" 73 | ---@type cmp_go_deep.Options 74 | opts = { 75 | -- See below for configuration options 76 | }, 77 | }, 78 | }, 79 | }, 80 | }, 81 | } 82 | ``` 83 | ### Default options 84 | ```lua 85 | { 86 | -- Enable/disable notifications. 87 | notifications = true, 88 | 89 | -- Filetypes to enable the source for. 90 | filetypes = { "go" }, 91 | 92 | -- How to get documentation for Go symbols. 93 | -- options: 94 | -- "hover" - LSP 'textDocument/hover'. Prettier. 95 | -- "regex" - faster and simpler. 96 | get_documentation_implementation = "regex", 97 | 98 | -- How to get the package names. 99 | -- options: 100 | -- "treesitter" - accurate but slower. 101 | -- "regex" - faster but can fail in edge cases. 102 | get_package_name_implementation = "regex", 103 | 104 | -- Whether to exclude vendored packages from completions. 105 | exclude_vendored_packages = false, 106 | 107 | -- Timeout in milliseconds for fetching documentation. 108 | -- Controls how long to wait for documentation to load. 109 | documentation_wait_timeout_ms = 100, 110 | 111 | -- Maximum time (in milliseconds) to wait before "locking-in" the current request and sending it to gopls. 112 | debounce_gopls_requests_ms = 250 113 | 114 | -- Maximum time (in milliseconds) to wait before "locking-in" the current request and loading data from cache. 115 | debounce_cache_requests_ms = 50 116 | 117 | -- Path to store the SQLite database 118 | -- Default: "~/.local/share/nvim/cmp_go_deep.sqlite3" 119 | db_path = vim.fn.stdpath("data") .. "/cmp_go_deep.sqlite3", 120 | 121 | -- Maximum size for the SQLite database in bytes. 122 | db_size_limit_bytes = 200 * 1024 * 1024, -- 200MB 123 | } 124 | ``` 125 | --- 126 | #### TODO 127 | - [x] Cache results for faster completions. 128 | - [x] Cross-project cache sharing for internal packages. 129 | - [x] Better memory usage. 130 | - [ ] ~~Remove the indirect dependency on ```cmp-nvim-lsp``` or ```blink.cmp's``` LSP source.~~ 131 | - [ ] Don't ignore package names while matching symbols. 132 | - [ ] Archive after [this issue](https://github.com/golang/go/issues/38528) is properly addressed. 133 | -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samiulsami/cmp-go-deep/8dcaaf3a450c6c06d67e6c305e346fbf247b6ded/demo.gif -------------------------------------------------------------------------------- /doc/cmp_go_deep.txt: -------------------------------------------------------------------------------- 1 | cmp_go_deep Options *cmp_go_deep-options* 2 | ============================================================================== 3 | DESCRIPTION *cmp_go_deep-description* 4 | 5 | This section describes the default options for the 6 | |cmp_go_deep| Go source that provides deep symbol 7 | completions for unimported packages. 8 | 9 | ============================================================================= 10 | SETUP-nvim-cmp *cmp_go_deep-setup-nvim-cmp* 11 | >lua 12 | { 13 | "hrsh7th/nvim-cmp", 14 | dependencies = { 15 | { "samiulsami/cmp-go-deep", dependencies = { "kkharji/sqlite.lua" } }, 16 | }, 17 | ... 18 | require("cmp").setup({ 19 | sources = {{ 20 | name = "go_deep", 21 | keyword_length = 3, 22 | max_item_count = 5, 23 | ---@module "cmp_go_deep" 24 | ---@type cmp_go_deep.Options 25 | option = { 26 | -- See below for configuration options 27 | }, 28 | }}, 29 | }) 30 | } 31 | < 32 | 33 | ============================================================================== 34 | SETUP-blink.cmp *cmp_go_deep-setup-blink* 35 | >lua 36 | { 37 | "saghen/blink.cmp", 38 | dependencies = { 39 | { "samiulsami/cmp-go-deep", dependencies = { "kkharji/sqlite.lua" } }, 40 | { "saghen/blink.compat" }, 41 | }, 42 | opts = { 43 | sources = { 44 | default = { 45 | "go_deep", 46 | }, 47 | providers = { 48 | go_deep = { 49 | name = "go_deep", 50 | module = "blink.compat.source", 51 | min_keyword_length = 3, 52 | max_items = 5, 53 | ---@module "cmp_go_deep" 54 | ---@type cmp_go_deep.Options 55 | opts = { 56 | -- See below for configuration options 57 | }, 58 | }, 59 | }, 60 | }, 61 | }, 62 | } 63 | < 64 | 65 | ============================================================================== 66 | DEFAULT OPTIONS *cmp_go_deep-default-options* 67 | 68 | The following options can be configured: 69 | 70 | • `notifications` 71 | Enable/disable notifications. 72 | Default: `true` 73 | 74 | • `filetypes` 75 | Filetypes to enable the source for. 76 | Default: `{"go"}` 77 | 78 | • `get_documentation_implementation` 79 | How to get documentation for Go symbols. 80 | Options: 81 | "hover" - LSP 'textDocument/hover'. Prettier. 82 | "regex" - faster and simpler. 83 | Default: `"regex"` 84 | 85 | • `get_package_name_implementation` 86 | How to get the package names. 87 | Options: 88 | "treesitter" - accurate but slower. 89 | "regex" - faster but can fail in edge cases. 90 | Default: `"regex"` 91 | 92 | • `exclude_vendored_packages` 93 | Whether to exclude vendored packages from completions. 94 | Default: `false` 95 | 96 | • `documentation_wait_timeout_ms` 97 | Timeout in milliseconds for fetching documentation. 98 | Controls how long to wait for documentation to load. 99 | Default: `100` (0.5 seconds) 100 | 101 | • `debounce_gopls_requests_ms` 102 | Timeout in milliseconds for "locking-in" the current request and sending it to gopls. 103 | Default: `250` 104 | 105 | • `debounce_cache_requests_ms` 106 | Timeout in milliseconds for "locking-in" the current request and loading data from cache. 107 | Default: `50` 108 | 109 | • `db_path` 110 | Path to store the SQLite database. 111 | Default: `vim.fn.stdpath("data") .. "/cmp_go_deep.sqlite3"` 112 | 113 | • `db_size_limit_bytes` 114 | Maximum size for the SQLite database in bytes. 115 | Default: `200 * 1024 * 1024` (200MB) 116 | ============================================================================== 117 | 118 | vim:tw=80:ts=4:ft=help:norl: 119 | -------------------------------------------------------------------------------- /doc/tags: -------------------------------------------------------------------------------- 1 | cmp_go_deep-default-options cmp_go_deep.txt /*cmp_go_deep-default-options* 2 | cmp_go_deep-description cmp_go_deep.txt /*cmp_go_deep-description* 3 | cmp_go_deep-options cmp_go_deep.txt /*cmp_go_deep-options* 4 | cmp_go_deep-setup-blink cmp_go_deep.txt /*cmp_go_deep-setup-blink* 5 | cmp_go_deep-setup-nvim-cmp cmp_go_deep.txt /*cmp_go_deep-setup-nvim-cmp* 6 | -------------------------------------------------------------------------------- /lua/cmp_go_deep/db.lua: -------------------------------------------------------------------------------- 1 | local sqlstmt = require("sqlite.stmt") 2 | ---@type sqlite_db 3 | local sqlite = require("sqlite.db") 4 | local math = require("math") 5 | 6 | ---@class cmp_go_deep.DB 7 | ---@field public setup fun(opts: cmp_go_deep.Options): cmp_go_deep.DB? 8 | ---@field public load fun(self, query_string: string): table 9 | ---@field public save fun(self, utils: cmp_go_deep.utils, symbol_information: table): nil 10 | ---@field private db sqlite_db 11 | ---@field private db_path string 12 | ---@field private max_db_size_bytes number 13 | ---@field private total_rows_estimate number -- pessimistic overestimation 14 | ---@field private notifications boolean 15 | ---@field private MAX_ROWS_THRESHOLD number 16 | local DB = {} 17 | local SCHEMA_VERSION = "0.0.6" 18 | 19 | ---@param opts cmp_go_deep.Options 20 | ---@return cmp_go_deep.DB? 21 | function DB.setup(opts) 22 | DB.notifications = opts.notifications 23 | DB.db_path = opts.db_path 24 | DB.max_db_size_bytes = opts.db_size_limit_bytes 25 | DB.db = sqlite:open(opts.db_path) 26 | DB.MAX_ROWS_THRESHOLD = math.min(100000, math.floor(opts.db_size_limit_bytes / 1024)) 27 | DB.MAX_ROWS_THRESHOLD = math.max(DB.MAX_ROWS_THRESHOLD, 10000) 28 | 29 | ---TODO: rtfm and fine-tune these 30 | local result = DB.db:eval("PRAGMA journal_mode = WAL") 31 | if type(result) == "table" and result[1] and result[1].journal_mode ~= "wal" then 32 | if DB.notifications then 33 | vim.notify("Failed to set journal_mode to WAL", vim.log.levels.WARN) 34 | end 35 | end 36 | DB.db:eval("PRAGMA synchronous = NORMAL") 37 | DB.db:eval("PRAGMA temp_store = MEMORY") 38 | DB.db:eval("PRAGMA cache_size = -10000") 39 | DB.db:eval("PRAGMA wal_autocheckpoint = 1000") 40 | DB.db:eval("PRAGMA page_size = 4096") 41 | DB.db:eval("PRAGMA auto_vacuum = incremental") 42 | DB.db:eval("PRAGMA max_page_count = " .. math.ceil(DB.max_db_size_bytes / 4096)) 43 | 44 | DB.db:eval([[ 45 | CREATE TABLE IF NOT EXISTS meta ( 46 | version TEXT PRIMARY KEY 47 | ); 48 | ]]) 49 | 50 | local res = DB.db:eval("SELECT version FROM meta;") 51 | if type(res) ~= "table" or #res == 0 or res[1].version ~= SCHEMA_VERSION then 52 | DB.db:eval("DELETE FROM meta") 53 | DB.db:eval("INSERT INTO meta (version) VALUES ('" .. SCHEMA_VERSION .. "')") 54 | DB.db:eval("DROP TABLE IF EXISTS gosymbols") 55 | DB.db:eval("DROP TABLE IF EXISTS gosymbols_fts") 56 | DB.db:eval("DROP TABLE IF EXISTS gosymbol_cache") 57 | end 58 | 59 | local tables = { 60 | [[ 61 | CREATE TABLE IF NOT EXISTS gosymbols ( 62 | id INTEGER PRIMARY KEY AUTOINCREMENT, 63 | hash TEXT UNIQUE NOT NULL, 64 | name TEXT NOT NULL, 65 | data TEXT NOT NULL, 66 | last_modified INTEGER NOT NULL 67 | ); 68 | ]], 69 | 70 | [[ 71 | CREATE VIRTUAL TABLE IF NOT EXISTS gosymbols_fts 72 | USING fts5(name, id UNINDEXED, tokenize='trigram', detail='none'); 73 | ]], 74 | } 75 | 76 | for _, sql in ipairs(tables) do 77 | if not DB.db:eval(sql) then 78 | if DB.notifications then 79 | vim.notify("[sqlite] failed to create table", vim.log.levels.ERROR) 80 | end 81 | return nil 82 | end 83 | end 84 | 85 | res = DB.db:eval("SELECT COUNT(*) as count FROM gosymbols") 86 | if type(res) ~= "table" or #res == 0 then 87 | if DB.notifications then 88 | vim.notify("[sqlite] error reading db row count", vim.log.levels.ERROR) 89 | end 90 | return nil 91 | end 92 | DB.total_rows_estimate = res[1].count 93 | 94 | if opts.debug then 95 | vim.notify("[sqlite] db row count: " .. DB.total_rows_estimate, vim.log.levels.INFO) 96 | end 97 | 98 | DB.db:eval("CREATE INDEX IF NOT EXISTS idx_last_modified ON gosymbols (last_modified DESC);") 99 | DB.db:eval("CREATE INDEX IF NOT EXISTS idx_hash ON gosymbols (hash DESC);") 100 | 101 | return DB 102 | end 103 | 104 | ---@param query_string string 105 | ---@return table 106 | function DB:load(query_string) 107 | local res = self.db:eval( 108 | [[ 109 | SELECT gosymbols.data FROM gosymbols 110 | JOIN gosymbols_fts ON gosymbols.id = gosymbols_fts.id 111 | WHERE gosymbols_fts.name LIKE '%' || ? || '%' 112 | ORDER BY gosymbols.last_modified DESC 113 | LIMIT 200; 114 | ]], 115 | { query_string } 116 | ) 117 | 118 | if type(res) ~= "table" or #res == 0 then 119 | return {} 120 | end 121 | 122 | local ret = {} 123 | for _, row in ipairs(res) do 124 | ret[#ret + 1] = vim.json.decode(row.data) 125 | end 126 | 127 | return ret 128 | end 129 | 130 | ---@return nil 131 | function DB:prune() 132 | if self.total_rows_estimate < self.MAX_ROWS_THRESHOLD then 133 | return 134 | end 135 | 136 | local res = self.db:eval("SELECT COUNT(*) as count FROM gosymbols") 137 | if type(res) ~= "table" or #res == 0 then 138 | if self.notifications then 139 | vim.notify("[sqlite] error reading db row count while pruning", vim.log.levels.ERROR) 140 | end 141 | return 142 | end 143 | self.total_rows_estimate = res[1].count 144 | 145 | local to_delete = math.floor(self.total_rows_estimate * 0.2) 146 | to_delete = math.min(to_delete, 5000) 147 | 148 | if to_delete == 0 then 149 | return 150 | end 151 | 152 | local delete_fts = sqlstmt:parse( 153 | self.db.conn, 154 | [[ 155 | DELETE FROM gosymbols_fts 156 | WHERE id IN ( 157 | SELECT id FROM gosymbols ORDER BY last_modified ASC LIMIT ? 158 | ); 159 | ]] 160 | ) 161 | 162 | local delete_gosymbols = sqlstmt:parse( 163 | self.db.conn, 164 | [[ 165 | DELETE FROM gosymbols 166 | WHERE id IN ( 167 | SELECT id FROM gosymbols ORDER BY last_modified ASC LIMIT ? 168 | ); 169 | ]] 170 | ) 171 | 172 | delete_gosymbols:bind({ to_delete }) 173 | delete_fts:bind({ to_delete }) 174 | 175 | if not self.db:eval("BEGIN TRANSACTION;") then 176 | if self.notifications then 177 | vim.notify("[sqlite] failed to begin transaction", vim.log.levels.ERROR) 178 | end 179 | return 180 | end 181 | 182 | ---@param msg string 183 | local rollback = function(msg) 184 | self.db:eval("ROLLBACK;") 185 | if self.notifications then 186 | vim.notify("[sqlite] " .. msg, vim.log.levels.ERROR) 187 | end 188 | end 189 | 190 | if delete_fts:step() ~= sqlite.flags["done"] then 191 | return rollback("failed to perform deletion of gosymbols_fts") 192 | end 193 | if not delete_fts:finalize() then 194 | return rollback("failed to finalize deletion of gosymbols_fts") 195 | end 196 | 197 | if delete_gosymbols:step() ~= sqlite.flags["done"] then 198 | return rollback("failed to perform deletion of gosymbols") 199 | end 200 | if not delete_gosymbols:finalize() then 201 | return rollback("failed to finalize deletion of gosymbols") 202 | end 203 | 204 | if not self.db:eval("END TRANSACTION;") then 205 | return rollback("failed to end transaction") 206 | end 207 | 208 | self.total_rows_estimate = self.total_rows_estimate - to_delete 209 | 210 | if not self.db:eval("PRAGMA incremental_vacuum(0)") then 211 | if self.notifications then 212 | vim.notify("[sqlite] failed to perform incremental vacuum", vim.log.levels.ERROR) 213 | end 214 | end 215 | end 216 | 217 | ---@param utils cmp_go_deep.utils 218 | ---@param symbol_information table 219 | function DB:save(utils, symbol_information) --- assumes that gopls doesn't return more than 100 workspace symbols 220 | local insert_gosymbols = sqlstmt:parse( 221 | self.db.conn, 222 | [[ 223 | INSERT OR REPLACE INTO gosymbols (name, data, hash, last_modified) 224 | VALUES (?, ?, ?, ?); 225 | ]] 226 | ) 227 | local last_insert_rowid_stmt = sqlstmt:parse( 228 | self.db.conn, 229 | [[ 230 | SELECT last_insert_rowid(); 231 | ]] 232 | ) 233 | local insert_gosymbols_fts = sqlstmt:parse( 234 | self.db.conn, 235 | [[ 236 | INSERT OR REPLACE INTO gosymbols_fts (name, id ) 237 | VALUES (?, ?); 238 | ]] 239 | ) 240 | 241 | local last_modified = os.time() 242 | if not self.db:eval("BEGIN TRANSACTION;") then 243 | if self.notifications then 244 | vim.notify("[sqlite] failed to begin transaction", vim.log.levels.ERROR) 245 | end 246 | return 247 | end 248 | 249 | ---@param msg string 250 | local rollback = function(msg) 251 | self.db:eval("ROLLBACK;") 252 | if self.notifications then 253 | vim.notify("[sqlite] " .. msg, vim.log.levels.ERROR) 254 | end 255 | end 256 | 257 | for _, symbol in ipairs(symbol_information) do 258 | local encoded = vim.json.encode(symbol) 259 | local hash = utils.deterministic_symbol_hash(symbol) 260 | 261 | insert_gosymbols:bind({ symbol.name, encoded, hash, last_modified }) 262 | if insert_gosymbols:step() ~= sqlite.flags["done"] then 263 | return rollback("failed to insert gosymbols row") 264 | end 265 | if insert_gosymbols:reset() ~= sqlite.flags["ok"] then 266 | return rollback("failed to reset insert_gosymbols") 267 | end 268 | 269 | if last_insert_rowid_stmt:step() ~= sqlite.flags["row"] then 270 | return rollback("failed to select id") 271 | end 272 | local id = last_insert_rowid_stmt:val(0) 273 | if last_insert_rowid_stmt:reset() ~= sqlite.flags["ok"] then 274 | return rollback("failed to reset last_insert_rowid_stmt") 275 | end 276 | 277 | insert_gosymbols_fts:bind({ symbol.name, id }) 278 | if insert_gosymbols_fts:step() ~= sqlite.flags["done"] then 279 | return rollback("failed to insert gosymbols_fts row") 280 | end 281 | if insert_gosymbols_fts:reset() ~= sqlite.flags["ok"] then 282 | return rollback("failed to reset insert_gosymbols_fts") 283 | end 284 | end 285 | 286 | if not insert_gosymbols:finalize() then 287 | return rollback("failed to finalize insert_gosymbols") 288 | end 289 | if not last_insert_rowid_stmt:finalize() then 290 | return rollback("failed to finalize last_insert_rowid_stmt") 291 | end 292 | if not insert_gosymbols_fts:finalize() then 293 | return rollback("failed to finalize insert_gosymbols_fts") 294 | end 295 | 296 | if not self.db:eval("END TRANSACTION;") then 297 | return rollback("failed to end transaction") 298 | end 299 | 300 | self.total_rows_estimate = self.total_rows_estimate + #symbol_information 301 | 302 | vim.schedule(function() 303 | self:prune() 304 | end) 305 | end 306 | 307 | return DB 308 | -------------------------------------------------------------------------------- /lua/cmp_go_deep/gopls_requests.lua: -------------------------------------------------------------------------------- 1 | ---@class cmp_go_deep.gopls_requests 2 | ---@field get_documentation fun(opts: cmp_go_deep.Options, gopls_client: vim.lsp.Client | nil, uri: string, range: lsp.Range): string|nil 3 | ---@field debounced_workspace_symbols fun(opts:cmp_go_deep.Options, gopls_client: vim.lsp.Client, bufnr: integer, cursor_prefix_word: string, callback: fun(items: lsp.SymbolInformation[]): nil): nil 4 | ---@field public workspace_symbols fun(opts:cmp_go_deep.Options, gopls_client: vim.lsp.Client, bufnr: integer, cursor_prefix_word: string, callback: fun(items: lsp.SymbolInformation[]): nil): nil 5 | local gopls_requests = {} 6 | 7 | ---@param opts cmp_go_deep.Options 8 | ---@param gopls_client vim.lsp.Client | nil 9 | ---@param uri string 10 | ---@param range lsp.Range 11 | ---@return string | nil 12 | ---TODO: try completionItem/resolve instead 13 | gopls_requests.get_documentation = function(opts, gopls_client, uri, range) 14 | if gopls_client == nil then 15 | if opts.notifications then 16 | vim.notify("gopls client is nil", vim.log.levels.WARN) 17 | end 18 | return nil 19 | end 20 | 21 | local params = { 22 | textDocument = { uri = uri }, 23 | position = range.start, 24 | } 25 | 26 | local markdown = "" 27 | 28 | gopls_client:request("textDocument/hover", params, function(err, result) 29 | if err then 30 | if opts.notifications then 31 | vim.notify("failed to get documentation: " .. err.message, vim.log.levels.WARN) 32 | end 33 | return 34 | end 35 | 36 | if result and result.contents then 37 | markdown = result.contents.value 38 | end 39 | end) 40 | 41 | vim.wait(opts.documentation_wait_timeout_ms, function() 42 | return markdown ~= "" 43 | end, 10) 44 | 45 | if markdown == "" and opts.notifications then 46 | if opts.notifications then 47 | vim.notify("timed out waiting for documentation", vim.log.levels.WARN) 48 | end 49 | end 50 | 51 | return markdown 52 | end 53 | 54 | ---@param opts cmp_go_deep.Options 55 | ---@param gopls_client vim.lsp.Client 56 | ---@param bufnr integer 57 | ---@param cursor_prefix_word string 58 | ---@param callback fun(items: lsp.SymbolInformation[]): nil 59 | -- stylua: ignore 60 | gopls_requests.workspace_symbols = function(opts, gopls_client, bufnr, cursor_prefix_word, callback) 61 | local success, _ = gopls_client:request("workspace/symbol", { query = cursor_prefix_word }, function(_, result) 62 | if not result then 63 | return 64 | end 65 | callback(result) 66 | end, bufnr) 67 | 68 | if not success then 69 | if opts.notifications then 70 | vim.notify("failed to get workspace symbols", vim.log.levels.WARN) 71 | end 72 | return 73 | end 74 | end 75 | 76 | return gopls_requests 77 | -------------------------------------------------------------------------------- /lua/cmp_go_deep/init.lua: -------------------------------------------------------------------------------- 1 | local utils = require("cmp_go_deep.utils") 2 | local gopls_requests = require("cmp_go_deep.gopls_requests") 3 | 4 | ---@class cmp_go_deep.Options 5 | ---@field public notifications boolean | nil -- whether to show notifications. default: true 6 | ---@field public filetypes string[] | nil -- filetypes to enable the source for 7 | ---@field public get_documentation_implementation "hover" | "regex" | nil -- how to get documentation. default: "regex" 8 | ---@field public get_package_name_implementation "treesitter" | "regex" | nil -- how to get package name (treesitter = slow but accurate | regex = fast but fails edge cases). default: "regex" 9 | ---@field public exclude_vendored_packages boolean | nil -- whether to exclude vendored packages. default: false 10 | ---@field public documentation_wait_timeout_ms integer | nil -- maximum time (in milliseconds) to wait for fetching documentation. default: 100 11 | ---@field public debounce_gopls_requests_ms integer | nil -- time to wait before "locking-in" the current request and sending it to gopls. default: 350 12 | ---@field public debounce_cache_requests_ms integer | nil -- time to wait before "locking-in" the current request and loading data from cache. default: 50 13 | ---@field public db_path string | nil -- where to store the sqlite db. default: ~/.local/share/nvim/cmp_go_deep.sqlite3 14 | ---@field public db_size_limit_bytes number | nil -- max db size in bytes. default: 200MB 15 | ---@field public debug boolean | nil -- whether to enable debug logging. default: false 16 | 17 | ---@type cmp_go_deep.Options 18 | local default_options = { 19 | notifications = true, 20 | filetypes = { "go" }, 21 | get_documentation_implementation = "regex", 22 | get_package_name_implementation = "regex", 23 | exclude_vendored_packages = false, 24 | documentation_wait_timeout_ms = 100, 25 | debounce_gopls_requests_ms = 250, 26 | debounce_cache_requests_ms = 50, 27 | db_path = vim.fn.stdpath("data") .. "/cmp_go_deep.sqlite3", 28 | db_size_limit_bytes = 200 * 1024 * 1024, 29 | debug = false, 30 | } 31 | 32 | local source = {} 33 | 34 | source.new = function() 35 | return setmetatable({}, { __index = source }) 36 | end 37 | 38 | source.is_available = function() 39 | if utils.get_gopls_client() == nil then 40 | return false 41 | end 42 | return true 43 | end 44 | 45 | source.get_trigger_characters = function() 46 | return { "[%w_]" } 47 | end 48 | 49 | source.complete = function(_, params, callback) 50 | local gopls_client = utils.get_gopls_client() 51 | if not gopls_client then 52 | return callback({ items = {}, isIncomplete = false }) 53 | end 54 | 55 | ---@type cmp_go_deep.Options 56 | source.opts = vim.tbl_deep_extend("force", default_options, params.option or params.opts or {}) 57 | 58 | local allowed_filetype = false 59 | for _, key in pairs(source.opts.filetypes) do 60 | if vim.bo.filetype == key then 61 | allowed_filetype = true 62 | break 63 | end 64 | end 65 | if not allowed_filetype then 66 | return callback({ items = {}, isIncomplete = false }) 67 | end 68 | 69 | local cursor_prefix_word = utils.get_cursor_prefix_word(0) 70 | if cursor_prefix_word:match("[%.]") or cursor_prefix_word:match("[^%w_]") then 71 | return callback({ items = {}, isIncomplete = false }) 72 | end 73 | 74 | if not source.cache then 75 | source.cache = require("cmp_go_deep.db").setup(source.opts) 76 | end 77 | 78 | if not gopls_requests.debounced_workspace_symbols then 79 | gopls_requests.debounced_workspace_symbols = 80 | utils.debounce(gopls_requests.workspace_symbols, source.opts.debounce_gopls_requests_ms) 81 | end 82 | 83 | if not utils.debounced_process_symbols then 84 | utils.debounced_process_symbols = utils.debounce(utils.process_symbols, source.opts.debounce_cache_requests_ms) 85 | end 86 | 87 | ---@type table 88 | local processed_items = {} 89 | 90 | local bufnr = vim.api.nvim_get_current_buf() 91 | local project_path = vim.fn.getcwd() 92 | local vendor_path_prefix = "file://" .. project_path .. "/vendor/" 93 | local project_path_prefix = "file://" .. project_path .. "/" 94 | 95 | utils:debounced_process_symbols( 96 | source.opts, 97 | bufnr, 98 | callback, 99 | vendor_path_prefix, 100 | project_path_prefix, 101 | source.cache:load(cursor_prefix_word), 102 | processed_items, 103 | true 104 | ) 105 | 106 | gopls_requests.debounced_workspace_symbols(source.opts, gopls_client, bufnr, cursor_prefix_word, function(result) 107 | if not result or #result == 0 then 108 | return callback({ items = {}, isIncomplete = false }) 109 | end 110 | 111 | local filtered_result = {} 112 | for _, symbol in ipairs(result) do 113 | if 114 | utils.symbol_to_completion_kind(symbol.kind) 115 | and symbol.name:match("^[A-Z]") 116 | and not symbol.location.uri:match("_test%.go$") 117 | and (#cursor_prefix_word > 2 or symbol.name:find(cursor_prefix_word, 1, true)) 118 | then 119 | if string.sub(symbol.location.uri, 1, #vendor_path_prefix) == vendor_path_prefix then 120 | symbol.isVendored = true 121 | symbol.location.uri = symbol.location.uri:sub(#vendor_path_prefix + 1) 122 | elseif string.sub(symbol.location.uri, 1, #project_path_prefix) == project_path_prefix then 123 | symbol.isLocal = true 124 | symbol.location.uri = symbol.location.uri:sub(#project_path_prefix + 1) 125 | end 126 | table.insert(filtered_result, symbol) 127 | end 128 | end 129 | 130 | source.cache:save(utils, filtered_result) 131 | 132 | local toProcess = filtered_result 133 | if #cursor_prefix_word > 2 then 134 | toProcess = source.cache:load(cursor_prefix_word) 135 | end 136 | 137 | utils:debounced_process_symbols( 138 | source.opts, 139 | bufnr, 140 | callback, 141 | vendor_path_prefix, 142 | project_path_prefix, 143 | toProcess, 144 | processed_items, 145 | true 146 | ) 147 | end) 148 | end 149 | 150 | ---@param completion_item lsp.CompletionItem 151 | ---@param callback fun(completion_item: lsp.CompletionItem|nil) 152 | function source:resolve(completion_item, callback) 153 | local symbol = completion_item.data 154 | 155 | if symbol == nil then 156 | return callback(nil) 157 | end 158 | 159 | ---@type cmp_go_deep.Options|nil 160 | local opts = symbol.opts 161 | if not opts then 162 | vim.notify("Warning: symbol data is missing options", vim.log.levels.WARN) 163 | return callback(completion_item) 164 | end 165 | 166 | if not type(symbol.location) == "table" then 167 | if opts.notifications then 168 | vim.notify("Warning: symbol location is missing", vim.log.levels.WARN) 169 | end 170 | return callback(completion_item) 171 | end 172 | 173 | ---@type lsp.Location 174 | local location = symbol.location 175 | if not location or not location.uri or not location.range then 176 | if opts.notifications then 177 | vim.notify("Warning: symbol location is missing", vim.log.levels.WARN) 178 | end 179 | return callback(completion_item) 180 | end 181 | 182 | vim.schedule(function() 183 | ---@type string|nil 184 | local documentation = utils.get_documentation(opts, location.uri, location.range) 185 | completion_item.documentation = { 186 | kind = "markdown", 187 | value = documentation or "", 188 | } 189 | callback(completion_item) 190 | end) 191 | end 192 | 193 | ---@param completion_item lsp.CompletionItem 194 | ---@param callback fun(completion_item: lsp.CompletionItem|nil) 195 | function source:execute(completion_item, callback) 196 | if vim.bo.filetype ~= "go" then 197 | return 198 | end 199 | 200 | local symbol = completion_item.data 201 | if not symbol then 202 | return 203 | end 204 | 205 | ---@type cmp_go_deep.Options|nil 206 | local opts = symbol.opts 207 | if not opts then 208 | return 209 | end 210 | 211 | local import_path = symbol.containerName 212 | local package_name = symbol.package_alias 213 | 214 | if not import_path then 215 | if opts.notifications then 216 | vim.notify("import path not found", vim.log.levels.WARN) 217 | end 218 | return 219 | end 220 | 221 | utils.add_import_statement(opts, symbol.bufnr, package_name, import_path) 222 | callback(completion_item) 223 | end 224 | 225 | return source 226 | -------------------------------------------------------------------------------- /lua/cmp_go_deep/test.lua: -------------------------------------------------------------------------------- 1 | --TODO: add this to ci 2 | 3 | local DB = require("cmp_go_deep.db") 4 | local utils = require("cmp_go_deep.utils") 5 | local math = math 6 | local os = os 7 | 8 | -- Random string generator 9 | local function random_string(length) 10 | local chars = "abcdefghijklmnopqrstuvwxyz" 11 | local result = {} 12 | for _ = 1, length do 13 | local index = math.random(1, #chars) 14 | table.insert(result, chars:sub(index, index)) 15 | end 16 | return table.concat(result) 17 | end 18 | 19 | local function generate_fake_symbols(n) 20 | local symbols = {} 21 | for i = 1, n do 22 | local name = "Sym_" .. random_string(math.random(3, 60)) .. tostring(i) 23 | local symbol = { 24 | name = name, 25 | kind = math.random(1, 25), -- LSP SymbolKind (1–25) 26 | containerName = "pkg" .. tostring(i % 100), 27 | location = { 28 | uri = "file:///fake/path/file" .. tostring(i % 500) .. ".go", 29 | range = { 30 | start = { line = 0, character = 0 }, 31 | ["end"] = { line = 0, character = 0 }, 32 | }, 33 | }, 34 | } 35 | table.insert(symbols, symbol) 36 | end 37 | return symbols 38 | end 39 | 40 | -- Main population script 41 | local function populate() 42 | math.randomseed(os.time()) 43 | 44 | local opts = { 45 | db_path = vim.fn.stdpath("data") .. "/cmp_go_deep.sqlite3", 46 | db_size_limit_bytes = 1024 * 1024 * 1024, 47 | } 48 | local db = DB.setup(opts) 49 | 50 | local total = 100000 51 | for _ = 1, total / 100 do 52 | local total = 100 53 | local data = generate_fake_symbols(total) 54 | db:save(utils, data) 55 | end 56 | vim.notify("[populate] Done! Inserted " .. total .. " symbols.", vim.log.levels.INFO) 57 | end 58 | 59 | populate() 60 | -------------------------------------------------------------------------------- /lua/cmp_go_deep/treesitter_implementations.lua: -------------------------------------------------------------------------------- 1 | local treesitter_implementations = {} 2 | 3 | ---@param bufnr (integer) 4 | ---@return TSNode | nil 5 | local function get_root_node(bufnr) 6 | if bufnr == nil then 7 | return nil 8 | end 9 | 10 | if not vim.api.nvim_buf_is_loaded(bufnr) then 11 | vim.fn.bufload(bufnr) 12 | end 13 | 14 | local parser = vim.treesitter.get_parser(bufnr, "go") 15 | if parser == nil then 16 | return nil 17 | end 18 | local root = nil 19 | parser = parser:parse() 20 | if parser ~= nil then 21 | root = parser[1]:root() 22 | end 23 | return root 24 | end 25 | 26 | ---@param opts cmp_go_deep.Options 27 | ---@param bufnr (integer) 28 | ---@param package_alias string | nil 29 | ---@param import_path string 30 | treesitter_implementations.add_import_statement = function(opts, bufnr, package_alias, import_path) 31 | local root = get_root_node(bufnr) 32 | if root == nil then 33 | return 34 | end 35 | 36 | if not package_alias then 37 | package_alias = "" 38 | else 39 | package_alias = package_alias .. " " 40 | end 41 | 42 | ---@type TSNode | nil 43 | local import_node = nil 44 | for node in root:iter_children() do 45 | if node:type() == "import_declaration" then 46 | import_node = node 47 | break 48 | end 49 | end 50 | 51 | if import_node then 52 | local start_row, _, end_row, _ = import_node:range() 53 | if import_node:named_child_count() == 1 then 54 | local child = import_node:named_child(0) 55 | if not child then 56 | if opts.notifications then 57 | vim.notify("could not parse import line with treesitter", vim.log.levels.WARN) 58 | end 59 | return 60 | end 61 | 62 | local type = child:type() 63 | if type == "interpreted_string_literal" or type == "raw_string_literal" or type == "import_spec" then 64 | vim.api.nvim_buf_set_lines(bufnr, start_row, end_row + 1, false, { 65 | "import (", 66 | "\t" .. vim.treesitter.get_node_text(child, bufnr), 67 | "\t" .. package_alias .. '"' .. import_path .. '"', 68 | ")", 69 | }) 70 | return 71 | end 72 | end 73 | 74 | local lines = vim.api.nvim_buf_get_lines(bufnr, start_row, end_row + 1, false) 75 | if not lines[#lines]:match("^%s*%)") then 76 | if opts.notifications then 77 | vim.notify("could not parse import block with treesitter", vim.log.levels.WARN) 78 | end 79 | return 80 | end 81 | 82 | table.insert(lines, #lines, "\t" .. package_alias .. '"' .. import_path .. '"') 83 | vim.api.nvim_buf_set_lines(bufnr, start_row, end_row + 1, false, lines) 84 | else 85 | local insert_line = 0 86 | for i, line in ipairs(vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)) do 87 | if line:match("^package%s+") then 88 | insert_line = i 89 | break 90 | end 91 | end 92 | 93 | vim.api.nvim_buf_set_lines(bufnr, insert_line, insert_line, false, { 94 | "", 95 | "import (", 96 | "\t" .. package_alias .. '"' .. import_path .. '"', 97 | ")", 98 | "", 99 | }) 100 | end 101 | end 102 | 103 | ---@param str string 104 | ---@return string 105 | ---@return integer count 106 | local function trim_quotes(str) 107 | return str:gsub('^["`]+', ""):gsub('["`]+$', "") 108 | end 109 | 110 | ---@param opts cmp_go_deep.Options 111 | ---@param bufnr (integer) 112 | ---@return table -- key: import path, value: package alias 113 | treesitter_implementations.get_imported_paths = function(opts, bufnr) 114 | local root = get_root_node(bufnr) 115 | if root == nil then 116 | return {} 117 | end 118 | 119 | ---@type TSNode | nil 120 | local import_node = nil 121 | for i = 0, root:named_child_count() - 1 do 122 | local node = root:named_child(i) 123 | if node and node:type() == "import_declaration" then 124 | import_node = node 125 | break 126 | end 127 | end 128 | if import_node == nil then 129 | return {} 130 | end 131 | 132 | local imported_paths = {} 133 | ---@param spec TSNode? 134 | local process_import_spec = function(spec) 135 | local path_node = spec and spec:field("path")[1] 136 | local name_node = spec and spec:field("name")[1] 137 | if path_node then 138 | local text = vim.treesitter.get_node_text(path_node, bufnr) 139 | if text then 140 | text = trim_quotes(text) 141 | local package_alias = name_node and vim.treesitter.get_node_text(name_node, bufnr) 142 | package_alias = package_alias or text:match("([^/]+)$") 143 | if package_alias == nil and opts.notifications then 144 | vim.notify("could not parse import line with treesitter " .. text, vim.log.levels.WARN) 145 | end 146 | imported_paths[text] = package_alias 147 | end 148 | end 149 | end 150 | 151 | for j = 0, import_node:named_child_count() - 1 do 152 | local child = import_node:named_child(j) 153 | if not child then 154 | goto continue 155 | end 156 | 157 | local type = child:type() 158 | if type == "import_spec" and child:named_child_count() > 0 then -- single line import 159 | process_import_spec(child:named_child(0)) 160 | goto continue 161 | end 162 | 163 | if type == "import_spec_list" then -- multiline import 164 | for k = 0, child:child_count() - 1 do 165 | process_import_spec(child:named_child(k)) 166 | end 167 | break 168 | end 169 | 170 | ::continue:: 171 | end 172 | 173 | return imported_paths 174 | end 175 | 176 | ---@param uri string 177 | ---@return string|nil 178 | treesitter_implementations.get_package_name = function(uri) 179 | local filepath = vim.uri_to_fname(uri) 180 | local bufnr = vim.fn.bufadd(filepath) 181 | local root = get_root_node(bufnr) 182 | if not root then 183 | return nil 184 | end 185 | 186 | for node in root:iter_children() do 187 | if node:type() == "package_clause" then 188 | for child in node:iter_children() do 189 | if child:type() == "package_identifier" then 190 | return vim.treesitter.get_node_text(child, bufnr) 191 | end 192 | end 193 | end 194 | end 195 | 196 | return nil 197 | end 198 | 199 | return treesitter_implementations 200 | -------------------------------------------------------------------------------- /lua/cmp_go_deep/utils.lua: -------------------------------------------------------------------------------- 1 | local gopls_requests = require("cmp_go_deep.gopls_requests") 2 | local treesitter_implementations = require("cmp_go_deep.treesitter_implementations") 3 | local completionItemKind = vim.lsp.protocol.CompletionItemKind 4 | 5 | ---@class cmp_go_deep.utils 6 | ---@field debounce fun(fn: fun(...), delay_ms: integer): fun(...) 7 | ---@field symbol_to_completion_kind fun(lspKind: lsp.SymbolKind): integer 8 | ---@field get_cursor_prefix_word fun(win_id: integer): string 9 | ---@field get_unique_package_alias fun(used_aliases: table, package_alias: string): string 10 | ---@field get_gopls_client fun(): vim.lsp.Client|nil 11 | ---@field get_documentation fun(opts: cmp_go_deep.Options, uri: string, range: lsp.Range): string|nil 12 | ---@field get_imported_paths fun(opts: cmp_go_deep.Options, bufnr: integer): tablebufnr: integer): table 13 | ---@field add_import_statement fun(opts: cmp_go_deep.Options, bufnr: integer, package_name: string | nil, import_path: string): nil 14 | ---@field get_package_name fun(opts: cmp_go_deep.Options, uri: string, package_name_cache: table): string|nil, boolean 15 | ---@field deterministic_symbol_hash fun(symbol: lsp.SymbolInformation): string 16 | ---@field process_symbols fun(self, opts: cmp_go_deep.Options, bufnr: integer, callback: any, vendor_prefix: string, project_path_prefix: string, symbols: table, processed_items: table, isIncomplete: boolean): nil 17 | ---@field debounced_process_symbols fun(self, opts: cmp_go_deep.Options, bufnr: integer, callback: any, vendor_prefix: string, project_path_prefix: string, symbols: table, processed_items: table, isIncomplete: boolean): nil 18 | local utils = {} 19 | 20 | local symbol_to_completion_kind = { 21 | [10] = completionItemKind.Enum, 22 | [11] = completionItemKind.Interface, 23 | [12] = completionItemKind.Function, 24 | [13] = completionItemKind.Variable, 25 | [14] = completionItemKind.Constant, 26 | [23] = completionItemKind.Struct, 27 | [26] = completionItemKind.TypeParameter, 28 | } 29 | 30 | ---@param fn fun( ...) 31 | ---@param delay_ms integer 32 | ---@return fun(...) 33 | utils.debounce = function(fn, delay_ms) 34 | local timer = vim.uv.new_timer() 35 | 36 | return function(...) 37 | timer:stop() 38 | local args = { ... } 39 | timer:start(delay_ms, 0, function() 40 | vim.schedule(function() 41 | local cur_args = args 42 | fn(unpack(cur_args)) 43 | end) 44 | end) 45 | end 46 | end 47 | 48 | ---@param lspKind lsp.SymbolKind 49 | ---@return integer 50 | utils.symbol_to_completion_kind = function(lspKind) 51 | return symbol_to_completion_kind[lspKind] 52 | end 53 | 54 | ---@param win_id integer 55 | ---@return string 56 | utils.get_cursor_prefix_word = function(win_id) 57 | local pos = vim.api.nvim_win_get_cursor(win_id) 58 | if #pos < 2 then 59 | return "" 60 | end 61 | 62 | local col = pos[2] 63 | local start_col = col 64 | local end_col = col 65 | 66 | local line = vim.api.nvim_get_current_line() 67 | 68 | while start_col > 0 and not line:sub(start_col - 1, start_col - 1):match("%s") do 69 | start_col = start_col - 1 70 | end 71 | 72 | return line:sub(start_col, end_col) 73 | end 74 | 75 | ---@return vim.lsp.Client | nil 76 | utils.get_gopls_client = function() 77 | local gopls_clients = vim.lsp.get_clients({ name = "gopls" }) 78 | if #gopls_clients > 0 then 79 | return gopls_clients[1] 80 | end 81 | return nil 82 | end 83 | 84 | ---@param opts cmp_go_deep.Options 85 | ---@param uri string 86 | ---@param range lsp.Range 87 | ---@return string | nil 88 | utils.get_documentation = function(opts, uri, range) 89 | if opts.get_documentation_implementation == "hover" then 90 | return gopls_requests.get_documentation(opts, utils.get_gopls_client(), uri, range) 91 | end 92 | 93 | --default to regex 94 | local filepath = vim.uri_to_fname(uri) 95 | local bufnr = vim.fn.bufadd(filepath) 96 | vim.fn.bufload(bufnr) 97 | 98 | local doc_lines = {} 99 | local start_line = range.start.line 100 | 101 | for i = start_line - 1, 0, -1 do 102 | local line = vim.api.nvim_buf_get_lines(bufnr, i, i + 1, false)[1] 103 | if not line or line:match("^%s*$") then 104 | break 105 | end 106 | 107 | local comment = line:match("^%s*//(.*)") 108 | if comment then 109 | table.insert(doc_lines, 1, vim.trim(comment)) 110 | else 111 | break 112 | end 113 | end 114 | 115 | if vim.tbl_isempty(doc_lines) then 116 | return nil 117 | end 118 | 119 | local ft = vim.bo[bufnr].filetype 120 | return string.format("```%s\n%s\n```", ft, table.concat(doc_lines, "\n")) 121 | end 122 | 123 | ---@param opts cmp_go_deep.Options 124 | ---@param bufnr (integer) 125 | ---@return table 126 | utils.get_imported_paths = function(opts, bufnr) 127 | return treesitter_implementations.get_imported_paths(opts, bufnr) 128 | end 129 | 130 | ---@param opts cmp_go_deep.Options 131 | ---@param bufnr (integer) 132 | ---@param package_alias string | nil 133 | ---@param import_path string 134 | utils.add_import_statement = function(opts, bufnr, package_alias, import_path) 135 | treesitter_implementations.add_import_statement(opts, bufnr, package_alias, import_path) 136 | end 137 | 138 | ---@param used_aliases table 139 | ---@param package_alias string 140 | ---@return string 141 | utils.get_unique_package_alias = function(used_aliases, package_alias) 142 | local alias = package_alias 143 | local i = 2 144 | while used_aliases[alias] do 145 | alias = package_alias .. i 146 | i = i + 1 147 | end 148 | return alias 149 | end 150 | 151 | ---@param opts cmp_go_deep.Options 152 | ---@param uri string 153 | ---@param package_name_cache table 154 | ---@return string|nil, boolean 155 | --- TODO: consider asking gopls for the package name, but this is probably faster 156 | utils.get_package_name = function(opts, uri, package_name_cache) 157 | local cached = package_name_cache[uri] 158 | if cached then 159 | if cached == "" then 160 | return nil, true 161 | end 162 | return cached, true 163 | end 164 | 165 | local stat = vim.uv.fs_stat(vim.uri_to_fname(uri)) 166 | if not (stat and stat.type == "file") then 167 | return nil, false 168 | end 169 | 170 | if opts.get_package_name_implementation == "treesitter" then 171 | local pkg = treesitter_implementations.get_package_name(uri) 172 | if pkg then 173 | package_name_cache[uri] = pkg 174 | return pkg, true 175 | end 176 | package_name_cache[uri] = "" 177 | return "", true 178 | end 179 | 180 | --default to regex 181 | --- FIXME: regex implementation doesn't work for package declarations like: "/* hehe */ package xd" 182 | local fname = vim.uri_to_fname(uri) 183 | if not fname then 184 | if opts.notifications then 185 | vim.notify("could not get file name from uri: " .. uri, vim.log.levels.WARN) 186 | end 187 | package_name_cache[uri] = "" 188 | return nil, true 189 | end 190 | 191 | local file, err = io.open(fname, "r") 192 | if not file then 193 | if opts.notifications then 194 | vim.notify("could not open file: " .. err, vim.log.levels.WARN) 195 | end 196 | package_name_cache[uri] = "" 197 | return nil, true 198 | end 199 | 200 | local in_block = false 201 | for line in file:lines() do 202 | local ln = line:match("^%s*(.-)%s*$") 203 | if not in_block and ln:find("^/%*") then 204 | in_block = true 205 | if ln:find("%*/") then 206 | in_block = false 207 | end 208 | elseif in_block then 209 | if ln:find("%*/") then 210 | in_block = false 211 | end 212 | elseif ln == "" or ln:find("^//") then 213 | -- ignore 214 | else 215 | local pkg = ln:match("^package%s+([%a_][%w_]*)") 216 | file:close() 217 | package_name_cache[uri] = pkg 218 | return pkg, true 219 | end 220 | end 221 | 222 | file:close() 223 | package_name_cache[uri] = "" 224 | return nil, true 225 | end 226 | 227 | ---@param symbol lsp.SymbolInformation 228 | ---@return string 229 | utils.deterministic_symbol_hash = function(symbol) 230 | local ordered = symbol.name 231 | .. " #" 232 | .. symbol.kind 233 | .. " #" 234 | .. symbol.containerName 235 | .. " #" 236 | .. string.format( 237 | "%d-%d,%d-%d", 238 | symbol.location.range.start.character, 239 | symbol.location.range.start.line, 240 | symbol.location.range["end"].character, 241 | symbol.location.range["end"].line 242 | ) 243 | return vim.fn.sha256(ordered) 244 | end 245 | 246 | ---@param opts cmp_go_deep.Options 247 | ---@param bufnr integer 248 | ---@param callback any 249 | ---@param vendor_path_prefix string 250 | ---@param project_path_prefix string 251 | ---@param symbols table 252 | ---@param processed_items table 253 | ---@param isIncomplete boolean 254 | function utils:process_symbols( 255 | opts, 256 | bufnr, 257 | callback, 258 | vendor_path_prefix, 259 | project_path_prefix, 260 | symbols, 261 | processed_items, 262 | isIncomplete 263 | ) 264 | local items = {} 265 | local package_name_cache = {} 266 | local imported_paths = utils.get_imported_paths(opts, bufnr) 267 | 268 | ---@type table 269 | local used_aliases = {} 270 | for _, v in pairs(imported_paths) do 271 | used_aliases[v] = true 272 | end 273 | 274 | local current_buf_uri = vim.uri_from_bufnr(bufnr) 275 | local current_buf_dir = vim.fn.fnamemodify(current_buf_uri, ":h") 276 | 277 | ---TODO: better type checking and error handling 278 | for _, symbol in ipairs(symbols) do 279 | local kind = utils.symbol_to_completion_kind(symbol.kind) 280 | local hash = self.deterministic_symbol_hash(symbol) 281 | if processed_items[hash] then 282 | goto continue 283 | end 284 | 285 | processed_items[hash] = true 286 | 287 | if symbol.isVendored then 288 | symbol.location.uri = vendor_path_prefix .. symbol.location.uri 289 | elseif symbol.isLocal then 290 | symbol.location.uri = project_path_prefix .. symbol.location.uri 291 | end 292 | 293 | local symbol_dir = vim.fn.fnamemodify(symbol.location.uri, ":h") 294 | 295 | if 296 | kind 297 | and not imported_paths[symbol.containerName] 298 | and symbol.location.uri ~= current_buf_uri 299 | and symbol_dir ~= current_buf_dir 300 | and not (opts.exclude_vendored_packages and symbol.isVendored) 301 | then 302 | local package_name, file_exists = utils.get_package_name(opts, symbol.location.uri, package_name_cache) 303 | if not file_exists then 304 | goto continue 305 | end 306 | 307 | if package_name == nil then 308 | package_name = symbol.containerName:match("([^/]+)$"):gsub("-", "_") 309 | end 310 | if not package_name then 311 | goto continue 312 | end 313 | 314 | local package_alias = utils.get_unique_package_alias(used_aliases, package_name) 315 | if package_alias ~= package_name then 316 | symbol.package_alias = package_alias 317 | end 318 | 319 | symbol.bufnr = bufnr 320 | symbol.opts = opts 321 | 322 | table.insert(items, { 323 | label = package_alias .. "." .. symbol.name, 324 | sortText = symbol.name, 325 | kind = kind, 326 | detail = '"' .. symbol.containerName .. '"', 327 | data = symbol, 328 | }) 329 | end 330 | 331 | ::continue:: 332 | end 333 | 334 | return callback({ items = items, isIncomplete = isIncomplete }) 335 | end 336 | 337 | return utils 338 | -------------------------------------------------------------------------------- /plugin/cmp_go_deep.lua: -------------------------------------------------------------------------------- 1 | require("cmp").register_source("go_deep", require("cmp_go_deep").new()) 2 | --------------------------------------------------------------------------------