local async = require('plenary.async') local M = {} M.scan_args = { max_count = 2500, max_depth = 50, no_ignore = false, } local function filter_files(files, max_count) -- Filter out empty entries files = vim.tbl_filter(function(file) return file ~= nil and file ~= '' end, files) if max_count and max_count > 0 then files = vim.list_slice(files, 1, max_count) end return files end ---@class CopilotChat.utils.ScanOpts ---@field max_count number? The maximum number of files to scan ---@field max_depth number? The maximum depth to scan ---@field pattern? string The glob pattern to match files ---@field hidden? boolean Whether to include hidden files ---@field no_ignore? boolean Whether to respect or ignore .gitignore --- Scan a directory ---@param path string ---@param opts CopilotChat.utils.ScanOpts? ---@async M.glob = async.wrap(function(path, opts, callback) opts = vim.tbl_deep_extend('force', M.scan_args, opts or {}) -- Use ripgrep if available if vim.fn.executable('rg') == 1 then local cmd = { 'rg' } if opts.pattern then table.insert(cmd, '-g') table.insert(cmd, opts.pattern) end if opts.max_depth then table.insert(cmd, '--max-depth') table.insert(cmd, tostring(opts.max_depth)) end if opts.no_ignore then table.insert(cmd, '--no-ignore') end if opts.hidden then table.insert(cmd, '--hidden') end table.insert(cmd, '--files') vim.system(cmd, { cwd = path, text = true }, function(result) local files = {} if result and result.code == 0 and result.stdout ~= '' then files = filter_files(vim.split(result.stdout, '\n'), opts.max_count) end callback(files) end) return end -- Fallback to vim.uv.fs_scandir local matchers = {} if opts.pattern then local file_pattern = vim.glob.to_lpeg(opts.pattern) local path_pattern = vim.lpeg.P(path .. '/') * file_pattern table.insert(matchers, function(name, dir) return file_pattern:match(name) or path_pattern:match(dir .. '/' .. name) end) end if not opts.hidden then table.insert(matchers, function(name) return not name:match('^%.') end) end local data = {} local next_dir = { path } local current_depths = { [path] = 1 } local function read_dir(err, fd) local current_dir = table.remove(next_dir, 1) local depth = current_depths[current_dir] or 1 if not err and fd then while true do local name, typ = vim.uv.fs_scandir_next(fd) if name == nil then break end local full_path = current_dir .. '/' .. name if typ == 'directory' and not name:match('^%.git') then if not opts.max_depth or depth < opts.max_depth then table.insert(next_dir, full_path) current_depths[full_path] = depth + 1 end else local match = true for _, matcher in ipairs(matchers) do if not matcher(name, current_dir) then match = false break end end if match then table.insert(data, full_path) end end end end if #next_dir == 0 then callback(data) else vim.uv.fs_scandir(next_dir[1], read_dir) end end vim.uv.fs_scandir(path, read_dir) end, 3) --- Grep a directory ---@param path string The path to search ---@param opts CopilotChat.utils.ScanOpts? M.grep = async.wrap(function(path, opts, callback) opts = vim.tbl_deep_extend('force', M.scan_args, opts or {}) local cmd = {} if vim.fn.executable('rg') == 1 then table.insert(cmd, 'rg') if opts.max_depth then table.insert(cmd, '--max-depth') table.insert(cmd, tostring(opts.max_depth)) end if opts.no_ignore then table.insert(cmd, '--no-ignore') end if opts.hidden then table.insert(cmd, '--hidden') end table.insert(cmd, '--files-with-matches') table.insert(cmd, '--ignore-case') if opts.pattern then table.insert(cmd, '-e') table.insert(cmd, opts.pattern) end elseif vim.fn.executable('grep') == 1 then table.insert(cmd, 'grep') table.insert(cmd, '-rli') if opts.pattern then table.insert(cmd, '-e') table.insert(cmd, opts.pattern) end end if vim.tbl_isempty(cmd) then error('No executable found for grep') return end vim.system(cmd, { cwd = path, text = true }, function(result) local files = {} if result and result.code == 0 and result.stdout ~= '' then files = filter_files(vim.split(result.stdout, '\n'), opts.max_count) end callback(files) end) end, 3) --- Read a file ---@param path string The file path ---@async function M.read_file(path) local err, fd = async.uv.fs_open(path, 'r', 438) if err or not fd then return nil end local err, stat = async.uv.fs_fstat(fd) if err or not stat then async.uv.fs_close(fd) return nil end local err, data = async.uv.fs_read(fd, stat.size, 0) async.uv.fs_close(fd) if err or not data then return nil end return data end --- Write data to a file ---@param path string The file path ---@param data string The data to write ---@return boolean function M.write_file(path, data) local err, fd = async.uv.fs_open(path, 'w', 438) if err or not fd then return false end local err = async.uv.fs_write(fd, data, 0) if err then async.uv.fs_close(fd) return false end async.uv.fs_close(fd) return true end --- Check if file paths are the same ---@param file1 string? The first file path ---@param file2 string? The second file path ---@return boolean function M.filename_same(file1, file2) if not file1 or not file2 then return false end return vim.fs.normalize(file1) == vim.fs.normalize(file2) end --- Get the filetype of a file ---@param filename string The file name ---@return string|nil function M.filetype(filename) local filetype = require('plenary.filetype') local ft = filetype.detect(filename, { fs_access = false, }) if ft == '' or not ft and not vim.in_fast_event() then ft = vim.filetype.match({ filename = filename }) end -- If filetype still not detected, default to 'text' -- Let content validation handle whether it's actually readable if not ft or ft == '' then return 'text' end return ft end --- Get the mimetype from filetype ---@param filetype string? ---@return string function M.filetype_to_mimetype(filetype) if not filetype or filetype == '' then return 'text/plain' end if filetype == 'json' or filetype == 'yaml' then return 'application/' .. filetype end if filetype == 'html' or filetype == 'css' then return 'text/' .. filetype end if filetype:find('/') then return filetype end return 'text/x-' .. filetype end --- Get the filetype from mimetype ---@param mimetype string? ---@return string function M.mimetype_to_filetype(mimetype) if not mimetype or mimetype == '' then return 'text' end local out = mimetype:gsub('^text/x%-', '') out = out:gsub('^text/', '') out = out:gsub('^application/', '') out = out:gsub('^image/', '') out = out:gsub('^video/', '') out = out:gsub('^audio/', '') return out end --- Convert a URI to a file name ---@param uri string The URI ---@return string function M.uri_to_filename(uri) if not uri or uri == '' then return uri end local ok, fname = pcall(vim.uri_to_fname, uri) if not ok or not fname or fname == '' then return uri end return fname end return M