summaryrefslogtreecommitdiff
path: root/lua/blink/cmp/sources/path
diff options
context:
space:
mode:
authorMike Vink <mike@pionative.com>2025-01-19 13:52:52 +0100
committerMike Vink <mike@pionative.com>2025-01-19 13:52:52 +0100
commitb77413ff8f59f380612074f0c9bd49093d8db695 (patch)
tree32c39a811ba96ed4ab0a1c81cce9f8d518ed7e31 /lua/blink/cmp/sources/path
Squashed 'mut/neovim/pack/plugins/start/blink.cmp/' content from commit 1cc3b1a
git-subtree-dir: mut/neovim/pack/plugins/start/blink.cmp git-subtree-split: 1cc3b1a908fbcfd15451c4772759549724f38524
Diffstat (limited to 'lua/blink/cmp/sources/path')
-rw-r--r--lua/blink/cmp/sources/path/fs.lua70
-rw-r--r--lua/blink/cmp/sources/path/init.lua89
-rw-r--r--lua/blink/cmp/sources/path/lib.lua125
-rw-r--r--lua/blink/cmp/sources/path/regex.lua10
4 files changed, 294 insertions, 0 deletions
diff --git a/lua/blink/cmp/sources/path/fs.lua b/lua/blink/cmp/sources/path/fs.lua
new file mode 100644
index 0000000..4ac79f0
--- /dev/null
+++ b/lua/blink/cmp/sources/path/fs.lua
@@ -0,0 +1,70 @@
+local async = require('blink.cmp.lib.async')
+local uv = vim.uv
+local fs = {}
+
+--- Scans a directory asynchronously in a loop until
+--- it finds all entries
+--- @param path string
+--- @return blink.cmp.Task
+function fs.scan_dir_async(path)
+ local max_entries = 200
+ return async.task.new(function(resolve, reject)
+ uv.fs_opendir(path, function(err, handle)
+ if err ~= nil or handle == nil then return reject(err) end
+
+ local all_entries = {}
+
+ local function read_dir()
+ uv.fs_readdir(handle, function(err, entries)
+ if err ~= nil or entries == nil then return reject(err) end
+
+ vim.list_extend(all_entries, entries)
+ if #entries == max_entries then
+ read_dir()
+ else
+ resolve(all_entries)
+ end
+ end)
+ end
+ read_dir()
+ end, max_entries)
+ end)
+end
+
+--- @param entries { name: string, type: string }[]
+--- @return blink.cmp.Task
+function fs.fs_stat_all(cwd, entries)
+ local tasks = {}
+ for _, entry in ipairs(entries) do
+ table.insert(
+ tasks,
+ async.task.new(function(resolve)
+ uv.fs_stat(cwd .. '/' .. entry.name, function(err, stat)
+ if err then return resolve(nil) end
+ resolve({ name = entry.name, type = entry.type, stat = stat })
+ end)
+ end)
+ )
+ end
+ return async.task.await_all(tasks):map(function(entries)
+ return vim.tbl_filter(function(entry) return entry ~= nil end, entries)
+ end)
+end
+
+--- @param path string
+--- @param byte_limit number
+--- @return blink.cmp.Task
+function fs.read_file(path, byte_limit)
+ return async.task.new(function(resolve, reject)
+ uv.fs_open(path, 'r', 438, function(open_err, fd)
+ if open_err or fd == nil then return reject(open_err) end
+ uv.fs_read(fd, byte_limit, 0, function(read_err, data)
+ uv.fs_close(fd, function() end)
+ if read_err or data == nil then return reject(read_err) end
+ resolve(data)
+ end)
+ end)
+ end)
+end
+
+return fs
diff --git a/lua/blink/cmp/sources/path/init.lua b/lua/blink/cmp/sources/path/init.lua
new file mode 100644
index 0000000..bb5f508
--- /dev/null
+++ b/lua/blink/cmp/sources/path/init.lua
@@ -0,0 +1,89 @@
+-- credit to https://github.com/hrsh7th/cmp-path for the original implementation
+-- and https://codeberg.org/FelipeLema/cmp-async-path for the async implementation
+
+--- @class blink.cmp.PathOpts
+--- @field trailing_slash boolean
+--- @field label_trailing_slash boolean
+--- @field get_cwd fun(context: blink.cmp.Context): string
+--- @field show_hidden_files_by_default boolean
+
+--- @class blink.cmp.Source
+--- @field opts blink.cmp.PathOpts
+local path = {}
+
+function path.new(opts)
+ local self = setmetatable({}, { __index = path })
+
+ --- @type blink.cmp.PathOpts
+ opts = vim.tbl_deep_extend('keep', opts, {
+ trailing_slash = true,
+ label_trailing_slash = true,
+ get_cwd = function(context) return vim.fn.expand(('#%d:p:h'):format(context.bufnr)) end,
+ show_hidden_files_by_default = false,
+ })
+ require('blink.cmp.config.utils').validate('sources.providers.path', {
+ trailing_slash = { opts.trailing_slash, 'boolean' },
+ label_trailing_slash = { opts.label_trailing_slash, 'boolean' },
+ get_cwd = { opts.get_cwd, 'function' },
+ show_hidden_files_by_default = { opts.show_hidden_files_by_default, 'boolean' },
+ }, opts)
+
+ self.opts = opts
+ return self
+end
+
+function path:get_trigger_characters() return { '/', '.' } end
+
+function path:get_completions(context, callback)
+ -- we use libuv, but the rest of the library expects to be synchronous
+ callback = vim.schedule_wrap(callback)
+
+ local lib = require('blink.cmp.sources.path.lib')
+
+ local dirname = lib.dirname(self.opts.get_cwd, context)
+ if not dirname then return callback({ is_incomplete_forward = false, is_incomplete_backward = false, items = {} }) end
+
+ local include_hidden = self.opts.show_hidden_files_by_default
+ or (string.sub(context.line, context.bounds.start_col, context.bounds.start_col) == '.' and context.bounds.length == 0)
+ or (
+ string.sub(context.line, context.bounds.start_col - 1, context.bounds.start_col - 1) == '.'
+ and context.bounds.length > 0
+ )
+ lib
+ .candidates(context, dirname, include_hidden, self.opts)
+ :map(
+ function(candidates)
+ callback({ is_incomplete_forward = false, is_incomplete_backward = false, items = candidates })
+ end
+ )
+ :catch(function() callback() end)
+end
+
+function path:resolve(item, callback)
+ require('blink.cmp.sources.path.fs')
+ .read_file(item.data.full_path, 1024)
+ :map(function(content)
+ local is_binary = content:find('\0')
+
+ -- binary file
+ if is_binary then
+ item.documentation = {
+ kind = 'plaintext',
+ value = 'Binary file',
+ }
+ -- highlight with markdown
+ else
+ local ext = vim.fn.fnamemodify(item.data.path, ':e')
+ item.documentation = {
+ kind = 'markdown',
+ value = '```' .. ext .. '\n' .. content .. '```',
+ }
+ end
+
+ return item
+ end)
+ :map(function(resolved_item) callback(resolved_item) end)
+ :catch(function() callback(item) end)
+end
+
+return path
diff --git a/lua/blink/cmp/sources/path/lib.lua b/lua/blink/cmp/sources/path/lib.lua
new file mode 100644
index 0000000..53fd970
--- /dev/null
+++ b/lua/blink/cmp/sources/path/lib.lua
@@ -0,0 +1,125 @@
+local regex = require('blink.cmp.sources.path.regex')
+local lib = {}
+
+--- @param get_cwd fun(context: blink.cmp.Context): string
+--- @param context blink.cmp.Context
+function lib.dirname(get_cwd, context)
+ -- HACK: move this :sub logic into the context?
+ -- it's not obvious that you need to avoid going back a char if the start_col == end_col
+ local line_before_cursor = context.line:sub(1, context.bounds.start_col - (context.bounds.length == 0 and 1 or 0))
+ local s = regex.PATH:match_str(line_before_cursor)
+ if not s then return nil end
+
+ local dirname = string.gsub(string.sub(line_before_cursor, s + 2), regex.NAME .. '*$', '') -- exclude '/'
+ local prefix = string.sub(line_before_cursor, 1, s + 1) -- include '/'
+
+ local buf_dirname = get_cwd(context)
+ if vim.api.nvim_get_mode().mode == 'c' then buf_dirname = vim.fn.getcwd() end
+ if prefix:match('%.%./$') then return vim.fn.resolve(buf_dirname .. '/../' .. dirname) end
+ if prefix:match('%./$') or prefix:match('"$') or prefix:match("'$") then
+ return vim.fn.resolve(buf_dirname .. '/' .. dirname)
+ end
+ if prefix:match('~/$') then return vim.fn.resolve(vim.fn.expand('~') .. '/' .. dirname) end
+ local env_var_name = prefix:match('%$([%a_]+)/$')
+ if env_var_name then
+ local env_var_value = vim.fn.getenv(env_var_name)
+ if env_var_value ~= vim.NIL then return vim.fn.resolve(env_var_value .. '/' .. dirname) end
+ end
+ if prefix:match('/$') then
+ local accept = true
+ -- Ignore URL components
+ accept = accept and not prefix:match('%a/$')
+ -- Ignore URL scheme
+ accept = accept and not prefix:match('%a+:/$') and not prefix:match('%a+://$')
+ -- Ignore HTML closing tags
+ accept = accept and not prefix:match('</$')
+ -- Ignore math calculation
+ accept = accept and not prefix:match('[%d%)]%s*/$')
+ -- Ignore / comment
+ accept = accept and (not prefix:match('^[%s/]*$') or not lib.is_slash_comment())
+ if accept then return vim.fn.resolve('/' .. dirname) end
+ end
+ -- Windows drive letter (C:/)
+ if prefix:match('(%a:)[/\\]$') then return vim.fn.resolve(prefix:match('(%a:)[/\\]$') .. '/' .. dirname) end
+ return nil
+end
+
+--- @param context blink.cmp.Context
+--- @param dirname string
+--- @param include_hidden boolean
+--- @param opts table
+function lib.candidates(context, dirname, include_hidden, opts)
+ local fs = require('blink.cmp.sources.path.fs')
+ local ranges = lib.get_text_edit_ranges(context)
+ return fs.scan_dir_async(dirname)
+ :map(function(entries) return fs.fs_stat_all(dirname, entries) end)
+ :map(function(entries)
+ return vim.tbl_filter(function(entry) return include_hidden or entry.name:sub(1, 1) ~= '.' end, entries)
+ end)
+ :map(function(entries)
+ return vim.tbl_map(
+ function(entry)
+ return lib.entry_to_completion_item(
+ entry,
+ dirname,
+ entry.type == 'directory' and ranges.directory or ranges.file,
+ opts
+ )
+ end,
+ entries
+ )
+ end)
+end
+
+function lib.is_slash_comment()
+ local commentstring = vim.bo.commentstring or ''
+ local no_filetype = vim.bo.filetype == ''
+ local is_slash_comment = false
+ is_slash_comment = is_slash_comment or commentstring:match('/%*')
+ is_slash_comment = is_slash_comment or commentstring:match('//')
+ return is_slash_comment and not no_filetype
+end
+
+--- @param entry { name: string, type: string, stat: table }
+--- @param dirname string
+--- @param range lsp.Range
+--- @param opts table
+--- @return blink.cmp.CompletionItem[]
+function lib.entry_to_completion_item(entry, dirname, range, opts)
+ local is_dir = entry.type == 'directory'
+ local CompletionItemKind = require('blink.cmp.types').CompletionItemKind
+ local insert_text = is_dir and opts.trailing_slash and entry.name .. '/' or entry.name
+ return {
+ label = (opts.label_trailing_slash and is_dir) and entry.name .. '/' or entry.name,
+ kind = is_dir and CompletionItemKind.Folder or CompletionItemKind.File,
+ insertText = insert_text,
+ textEdit = { newText = insert_text, range = range },
+ sortText = (is_dir and '1' or '2') .. entry.name:lower(), -- Sort directories before files
+ data = { path = entry.name, full_path = dirname .. '/' .. entry.name, type = entry.type, stat = entry.stat },
+ }
+end
+
+--- @param context blink.cmp.Context
+--- @return { file: lsp.Range, directory: lsp.Range }
+function lib.get_text_edit_ranges(context)
+ local line_before_cursor = context.line:sub(1, context.cursor[2])
+ local next_letter_is_slash = context.line:sub(context.cursor[2] + 1, context.cursor[2] + 1) == '/'
+
+ local parts = vim.split(line_before_cursor, '/')
+ local last_part = parts[#parts]
+
+ -- TODO: return the insert and replace ranges, instead of only the insert range
+ return {
+ file = {
+ start = { line = context.cursor[1] - 1, character = context.cursor[2] - #last_part },
+ ['end'] = { line = context.cursor[1] - 1, character = context.cursor[2] },
+ },
+ directory = {
+ start = { line = context.cursor[1] - 1, character = context.cursor[2] - #last_part },
+ -- replace the slash after the cursor, if it exists
+ ['end'] = { line = context.cursor[1] - 1, character = context.cursor[2] + (next_letter_is_slash and 1 or 0) },
+ },
+ }
+end
+
+return lib
diff --git a/lua/blink/cmp/sources/path/regex.lua b/lua/blink/cmp/sources/path/regex.lua
new file mode 100644
index 0000000..af27d25
--- /dev/null
+++ b/lua/blink/cmp/sources/path/regex.lua
@@ -0,0 +1,10 @@
+local NAME_REGEX = '\\%([^/\\\\:\\*?<>\'"`\\|]\\)'
+local PATH_REGEX =
+ assert(vim.regex(([[\%(\%(/PAT*[^/\\\\:\\*?<>\'"`\\| .~]\)\|\%(/\.\.\)\)*/\zePAT*$]]):gsub('PAT', NAME_REGEX)))
+
+return {
+ --- Lua pattern for matching file names
+ NAME = '[^/\\:*?<>\'"`|]',
+ --- Vim regex for matching file paths
+ PATH = PATH_REGEX,
+}