local api = vim.api
local fn = vim.fn
local lsp = vim.lsp
---@class avante.utils: LazyUtilCore
---@field tokens avante.utils.tokens
---@field root avante.utils.root
---@field file avante.utils.file
---@field history avante.utils.history
---@field environment avante.utils.environment
---@field lsp avante.utils.lsp
local M = {}
setmetatable(M, {
__index = function(t, k)
local ok, lazyutil = pcall(require, "lazy.core.util")
if ok and lazyutil[k] then return lazyutil[k] end
---@diagnostic disable-next-line: no-unknown
t[k] = require("avante.utils." .. k)
return t[k]
end,
})
---Check if a plugin is installed
---@param plugin string
---@return boolean
function M.has(plugin)
local ok, LazyConfig = pcall(require, "lazy.core.config")
if ok then return LazyConfig.plugins[plugin] ~= nil end
local res, _ = pcall(require, plugin)
return res
end
local _is_win = nil
function M.is_win()
if _is_win == nil then _is_win = jit.os:find("Windows") ~= nil end
return _is_win
end
M.path_sep = (function()
if M.is_win() then
return "\\"
else
return "/"
end
end)()
---@return "linux" | "darwin" | "windows"
function M.get_os_name()
local os_name = vim.uv.os_uname().sysname
if os_name == "Linux" then
return "linux"
elseif os_name == "Darwin" then
return "darwin"
elseif os_name == "Windows_NT" then
return "windows"
else
error("Unsupported operating system: " .. os_name)
end
end
function M.get_system_info()
local os_name = vim.loop.os_uname().sysname
local os_version = vim.loop.os_uname().release
local os_machine = vim.loop.os_uname().machine
local lang = os.getenv("LANG")
local res = string.format(
"- Platform: %s-%s-%s\n- Shell: %s\n- Language: %s\n- Current date: %s",
os_name,
os_version,
os_machine,
vim.o.shell,
lang,
os.date("%Y-%m-%d")
)
local project_root = M.root.get()
if project_root then res = res .. string.format("\n- Project root: %s", project_root) end
local is_git_repo = vim.fn.isdirectory(".git") == 1
if is_git_repo then res = res .. "\n- The user is operating inside a git repository" end
return res
end
---@param input_cmd string
---@param shell_cmd string?
local function get_cmd_for_shell(input_cmd, shell_cmd)
local shell = vim.o.shell:lower()
local cmd ---@type string
-- powershell then we can just run the cmd
if shell:match("powershell") or shell:match("pwsh") then
cmd = input_cmd
elseif fn.has("win32") > 0 then
cmd = 'powershell.exe -NoProfile -Command "' .. input_cmd:gsub('"', "'") .. '"'
else
-- linux and macos we will just do sh -c
shell_cmd = shell_cmd or "sh -c"
cmd = shell_cmd .. " " .. fn.shellescape(input_cmd)
end
return cmd
end
--- This function will run given shell command synchronously.
---@param input_cmd string
---@param shell_cmd string?
---@return vim.SystemCompleted
function M.shell_run(input_cmd, shell_cmd)
local cmd = get_cmd_for_shell(input_cmd, shell_cmd)
local output = fn.system(cmd)
local code = vim.v.shell_error
return { stdout = output, code = code }
end
---@param input_cmd string
---@param shell_cmd string?
---@param on_complete fun(output: string, code: integer)
---@param cwd? string
function M.shell_run_async(input_cmd, shell_cmd, on_complete, cwd)
local cmd = get_cmd_for_shell(input_cmd, shell_cmd)
---@type string[]
local output = {}
fn.jobstart(cmd, {
on_stdout = function(_, data)
if not data then return end
vim.list_extend(output, data)
end,
on_stderr = function(_, data)
if not data then return end
vim.list_extend(output, data)
end,
on_exit = function(_, exit_code) on_complete(table.concat(output, "\n"), exit_code) end,
cwd = cwd,
})
end
---@see https://github.com/LazyVim/LazyVim/blob/main/lua/lazyvim/util/toggle.lua
---
---@alias _ToggleSet fun(state: boolean): nil
---@alias _ToggleGet fun(): boolean
---
---@class ToggleBind
---@field name string
---@field set _ToggleSet
---@field get _ToggleGet
---
---@class ToggleBind.wrap: ToggleBind
---@operator call:boolean
---@param toggle ToggleBind
function M.toggle_wrap(toggle)
return setmetatable(toggle, {
__call = function()
toggle.set(not toggle.get())
local state = toggle.get()
if state then
M.info("enabled: " .. toggle.name)
else
M.warn("disabled: " .. toggle.name)
end
return state
end,
}) --[[@as ToggleBind.wrap]]
end
-- Wrapper around vim.keymap.set that will
-- not create a keymap if a lazy key handler exists.
-- It will also set `silent` to true by default.
--
---@param mode string|string[] Mode short-name, see |nvim_set_keymap()|.
--- Can also be list of modes to create mapping on multiple modes.
---@param lhs string Left-hand side |{lhs}| of the mapping.
---@param rhs string|function Right-hand side |{rhs}| of the mapping, can be a Lua function.
---
---@param opts? vim.keymap.set.Opts
function M.safe_keymap_set(mode, lhs, rhs, opts)
---@type boolean
local ok
---@module "lazy.core.handler"
local H
ok, H = pcall(require, "lazy.core.handler")
if not ok then
M.debug("lazy.nvim is not available. Avante will use vim.keymap.set")
vim.keymap.set(mode, lhs, rhs, opts)
return
end
local Keys = H.handlers.keys
---@cast Keys LazyKeysHandler
local modes = type(mode) == "string" and { mode } or mode
---@cast modes -string
---@param m string
---@diagnostic disable-next-line: undefined-field
modes = vim.tbl_filter(function(m) return not (Keys and Keys.have and Keys:have(lhs, m)) end, modes)
-- don't create keymap if a lazy keys handler exists
if #modes > 0 then
opts = opts or {}
opts.silent = opts.silent ~= false
if opts.remap and not vim.g.vscode then
---@diagnostic disable-next-line: no-unknown
opts.remap = nil
end
vim.keymap.set(mode, lhs, rhs, opts)
end
end
---@param str string
---@param opts? {suffix?: string, prefix?: string}
function M.trim(str, opts)
local res = str
if not opts then return res end
if opts.suffix then
res = res:sub(#res - #opts.suffix + 1) == opts.suffix and res:sub(1, #res - #opts.suffix) or res
end
if opts.prefix then res = res:sub(1, #opts.prefix) == opts.prefix and res:sub(#opts.prefix + 1) or res end
return res
end
function M.in_visual_mode()
local current_mode = fn.mode()
return current_mode == "v" or current_mode == "V" or current_mode == ""
end
---Get the selected content and range in Visual mode
---@return avante.SelectionResult | nil Selected content and range
function M.get_visual_selection_and_range()
if not M.in_visual_mode() then return nil end
local Range = require("avante.range")
local SelectionResult = require("avante.selection_result")
-- Get the start and end positions of Visual mode
local start_pos = fn.getpos("v")
local end_pos = fn.getpos(".")
-- Get the start and end line and column numbers
local start_line = start_pos[2]
local start_col = start_pos[3]
local end_line = end_pos[2]
local end_col = end_pos[3]
-- If the start point is after the end point, swap them
if start_line > end_line or (start_line == end_line and start_col > end_col) then
start_line, end_line = end_line, start_line
start_col, end_col = end_col, start_col
end
local content = "" -- luacheck: ignore
local range = Range:new({ lnum = start_line, col = start_col }, { lnum = end_line, col = end_col })
-- Check if it's a single-line selection
if start_line == end_line then
-- Get partial content of a single line
local line = fn.getline(start_line)
-- content = string.sub(line, start_col, end_col)
content = line
else
-- Multi-line selection: Get all lines in the selection
local lines = fn.getline(start_line, end_line)
-- Extract partial content of the first line
-- lines[1] = string.sub(lines[1], start_col)
-- Extract partial content of the last line
-- lines[#lines] = string.sub(lines[#lines], 1, end_col)
-- Concatenate all lines in the selection into a string
if type(lines) == "table" then
content = table.concat(lines, "\n")
else
content = lines
end
end
if not content then return nil end
local filepath = fn.expand("%:p")
local filetype = M.get_filetype(filepath)
-- Return the selected content and range
return SelectionResult:new(filepath, filetype, content, range)
end
---Wrapper around `api.nvim_buf_get_lines` which defaults to the current buffer
---@param start integer
---@param end_ integer
---@param buf integer?
---@return string[]
function M.get_buf_lines(start, end_, buf) return api.nvim_buf_get_lines(buf or 0, start, end_, false) end
---Get cursor row and column as (1, 0) based
---@param win_id integer?
---@return integer, integer
---@diagnostic disable-next-line: redundant-return-value
function M.get_cursor_pos(win_id) return unpack(api.nvim_win_get_cursor(win_id or 0)) end
---Check if the buffer is likely to have actionable conflict markers
---@param bufnr integer?
---@return boolean
function M.is_valid_buf(bufnr)
bufnr = bufnr or 0
return #vim.bo[bufnr].buftype == 0 and vim.bo[bufnr].modifiable
end
--- Check if a NUI container is valid:
--- 1. Container must exist
--- 2. Container must have a valid buffer number
--- 3. Container must have a valid window ID (optional, based on check_winid parameter)
--- Always returns a boolean value
---@param container NuiSplit | nil
---@param check_winid boolean? Whether to check window validity, defaults to false
---@return boolean
function M.is_valid_container(container, check_winid)
-- Default check_winid to false if not specified
if check_winid == nil then check_winid = false end
-- First check if container exists
if container == nil then return false end
-- Check buffer validity
if container.bufnr == nil or not api.nvim_buf_is_valid(container.bufnr) then return false end
-- Check window validity if requested
if check_winid then
if container.winid == nil or not api.nvim_win_is_valid(container.winid) then return false end
end
return true
end
---@param name string?
---@return table
function M.get_hl(name)
if not name then return {} end
return api.nvim_get_hl(0, { name = name, link = false })
end
--- vendor from lazy.nvim for early access and override
---@param path string
---@return string
function M.norm(path) return vim.fs.normalize(path) end
---@param msg string|string[]
---@param opts? LazyNotifyOpts
function M.notify(msg, opts)
if vim.in_fast_event() then
return vim.schedule(function() M.notify(msg, opts) end)
end
opts = opts or {}
if type(msg) == "table" then
---@diagnostic disable-next-line: no-unknown
msg = table.concat(vim.tbl_filter(function(line) return line or false end, msg), "\n")
end
---@diagnostic disable-next-line: undefined-field
if opts.stacktrace then
---@diagnostic disable-next-line: undefined-field
msg = msg .. M.pretty_trace({ level = opts.stacklevel or 2 })
end
local lang = opts.lang or "markdown"
---@diagnostic disable-next-line: undefined-field
local n = opts.once and vim.notify_once or vim.notify
n(msg, opts.level or vim.log.levels.INFO, {
on_open = function(win)
local ok = pcall(function() vim.treesitter.language.add("markdown") end)
if not ok then pcall(require, "nvim-treesitter") end
vim.wo[win].conceallevel = 3
vim.wo[win].concealcursor = ""
vim.wo[win].spell = false
local buf = api.nvim_win_get_buf(win)
if not pcall(vim.treesitter.start, buf, lang) then
vim.bo[buf].filetype = lang
vim.bo[buf].syntax = lang
end
end,
title = opts.title or "Avante",
})
end
---@param msg string|string[]
---@param opts? LazyNotifyOpts
function M.error(msg, opts)
opts = opts or {}
opts.level = vim.log.levels.ERROR
M.notify(msg, opts)
end
---@param msg string|string[]
---@param opts? LazyNotifyOpts
function M.info(msg, opts)
opts = opts or {}
opts.level = vim.log.levels.INFO
M.notify(msg, opts)
end
---@param msg string|string[]
---@param opts? LazyNotifyOpts
function M.warn(msg, opts)
opts = opts or {}
opts.level = vim.log.levels.WARN
M.notify(msg, opts)
end
function M.debug(...)
if not require("avante.config").debug then return end
local args = { ... }
if #args == 0 then return end
-- Get caller information
local info = debug.getinfo(2, "Sl")
local caller_source = info.source:match("@(.+)$") or "unknown"
local caller_module = caller_source:gsub("^.*/lua/", ""):gsub("%.lua$", ""):gsub("/", ".")
local timestamp = os.date("%Y-%m-%d %H:%M:%S")
local formated_args = {
"[" .. timestamp .. "] [AVANTE] [DEBUG] [" .. caller_module .. ":" .. info.currentline .. "]",
}
for _, arg in ipairs(args) do
if type(arg) == "string" then
table.insert(formated_args, arg)
else
table.insert(formated_args, vim.inspect(arg))
end
end
print(unpack(formated_args))
end
function M.tbl_indexof(tbl, value)
for i, v in ipairs(tbl) do
if v == value then return i end
end
return nil
end
function M.update_win_options(winid, opt_name, key, value)
local cur_opt_value = api.nvim_get_option_value(opt_name, { win = winid })
if cur_opt_value:find(key .. ":") then
cur_opt_value = cur_opt_value:gsub(key .. ":[^,]*", key .. ":" .. value)
else
if #cur_opt_value > 0 then cur_opt_value = cur_opt_value .. "," end
cur_opt_value = cur_opt_value .. key .. ":" .. value
end
api.nvim_set_option_value(opt_name, cur_opt_value, { win = winid })
end
function M.get_win_options(winid, opt_name, key)
local cur_opt_value = api.nvim_get_option_value(opt_name, { win = winid })
if not cur_opt_value then return end
local pieces = vim.split(cur_opt_value, ",")
for _, piece in ipairs(pieces) do
local kv_pair = vim.split(piece, ":")
if kv_pair[1] == key then return kv_pair[2] end
end
end
function M.get_winid(bufnr)
for _, winid in ipairs(api.nvim_list_wins()) do
if api.nvim_win_get_buf(winid) == bufnr then return winid end
end
end
function M.unlock_buf(bufnr)
vim.bo[bufnr].readonly = false
vim.bo[bufnr].modified = false
vim.bo[bufnr].modifiable = true
end
function M.lock_buf(bufnr)
vim.cmd("noautocmd stopinsert")
vim.bo[bufnr].readonly = true
vim.bo[bufnr].modified = false
vim.bo[bufnr].modifiable = false
end
---@param winnr? number
---@return nil
function M.scroll_to_end(winnr)
winnr = winnr or 0
local bufnr = api.nvim_win_get_buf(winnr)
local lnum = api.nvim_buf_line_count(bufnr)
local last_line = api.nvim_buf_get_lines(bufnr, -2, -1, true)[1]
api.nvim_win_set_cursor(winnr, { lnum, api.nvim_strwidth(last_line) })
end
---@param bufnr nil|integer
---@return nil
function M.buf_scroll_to_end(bufnr)
for _, winnr in ipairs(M.buf_list_wins(bufnr or 0)) do
M.scroll_to_end(winnr)
end
end
---@param bufnr nil|integer
---@return integer[]
function M.buf_list_wins(bufnr)
local wins = {}
if not bufnr or bufnr == 0 then bufnr = api.nvim_get_current_buf() end
for _, winnr in ipairs(api.nvim_list_wins()) do
if api.nvim_win_is_valid(winnr) and api.nvim_win_get_buf(winnr) == bufnr then table.insert(wins, winnr) end
end
return wins
end
local sidebar_buffer_var_name = "is_avante_sidebar_buffer"
function M.mark_as_sidebar_buffer(bufnr) api.nvim_buf_set_var(bufnr, sidebar_buffer_var_name, true) end
function M.is_sidebar_buffer(bufnr)
local ok, v = pcall(api.nvim_buf_get_var, bufnr, sidebar_buffer_var_name)
if not ok then return false end
return v == true
end
function M.trim_spaces(s) return s:match("^%s*(.-)%s*$") end
function M.fallback(v, default_value) return type(v) == "nil" and default_value or v end
---Join URL parts together, handling slashes correctly
---@param ... string URL parts to join
---@return string Joined URL
function M.url_join(...)
local parts = { ... }
local result = parts[1] or ""
for i = 2, #parts do
local part = parts[i]
if not part or part == "" then goto continue end
-- Remove trailing slash from result if present
if result:sub(-1) == "/" then result = result:sub(1, -2) end
-- Remove leading slash from part if present
if part:sub(1, 1) == "/" then part = part:sub(2) end
-- Join with slash
result = result .. "/" .. part
::continue::
end
if result:sub(-1) == "/" then result = result:sub(1, -2) end
return result
end
-- luacheck: push no max comment line length
---@param type_name "'nil'" | "'number'" | "'string'" | "'boolean'" | "'table'" | "'function'" | "'thread'" | "'userdata'" | "'list'" | '"map"'
---@return boolean
function M.is_type(type_name, v)
---@diagnostic disable-next-line: deprecated
local islist = vim.islist or vim.tbl_islist
if type_name == "list" then return islist(v) end
if type_name == "map" then return type(v) == "table" and not islist(v) end
return type(v) == type_name
end
-- luacheck: pop
---@param code string
---@return string
function M.get_indentation(code)
if not code then return "" end
return code:match("^%s*") or ""
end
--- remove indentation from code: spaces or tabs
function M.remove_indentation(code)
if not code then return code end
return code:gsub("^%s*", "")
end
function M.relative_path(absolute)
local relative = fn.fnamemodify(absolute, ":.")
if string.sub(relative, 0, 1) == "/" then return fn.fnamemodify(absolute, ":t") end
return relative
end
function M.get_doc()
local absolute = api.nvim_buf_get_name(0)
local params = lsp.util.make_position_params(0, "utf-8")
local position = {
row = params.position.line + 1,
col = params.position.character,
}
local doc = {
uri = params.textDocument.uri,
version = api.nvim_buf_get_var(0, "changedtick"),
relativePath = M.relative_path(absolute),
insertSpaces = vim.o.expandtab,
tabSize = fn.shiftwidth(),
indentSize = fn.shiftwidth(),
position = position,
}
return doc
end
function M.prepend_line_number(content, start_line)
start_line = start_line or 1
local lines = vim.split(content, "\n")
local result = {}
for i, line in ipairs(lines) do
i = i + start_line - 1
table.insert(result, "L" .. i .. ": " .. line)
end
return table.concat(result, "\n")
end
function M.trim_line_number(line) return line:gsub("^L%d+: ", "") end
function M.trim_all_line_numbers(content)
return vim
.iter(vim.split(content, "\n"))
:map(function(line)
local new_line = M.trim_line_number(line)
return new_line
end)
:join("\n")
end
function M.debounce(func, delay)
local timer_id = nil
return function(...)
local args = { ... }
if timer_id then fn.timer_stop(timer_id) end
timer_id = fn.timer_start(delay, function()
func(unpack(args))
timer_id = nil
end)
return timer_id
end
end
function M.winline(winid)
local current_win = api.nvim_get_current_win()
api.nvim_set_current_win(winid)
local line = fn.winline()
api.nvim_set_current_win(current_win)
return line
end
function M.get_project_root() return M.root.get() end
function M.is_same_file_ext(target_ext, filepath)
local ext = fn.fnamemodify(filepath, ":e")
if (target_ext == "tsx" and ext == "ts") or (target_ext == "ts" and ext == "tsx") then return true end
if (target_ext == "jsx" and ext == "js") or (target_ext == "js" and ext == "jsx") then return true end
return ext == target_ext
end
-- Get recent filepaths in the same project and same file ext
---@param limit? integer
---@param filenames? string[]
---@param same_file_ext? boolean
---@return string[]
function M.get_recent_filepaths(limit, filenames, same_file_ext)
local project_root = M.get_project_root()
local current_ext = fn.expand("%:e")
local oldfiles = vim.v.oldfiles
local recent_files = {}
for _, file in ipairs(oldfiles) do
if vim.startswith(file, project_root) then
local has_ext = file:match("%.%w+$")
if not has_ext then goto continue end
if same_file_ext then
if not M.is_same_file_ext(current_ext, file) then goto continue end
end
if filenames and #filenames > 0 then
for _, filename in ipairs(filenames) do
if file:find(filename) then table.insert(recent_files, file) end
end
else
table.insert(recent_files, file)
end
if #recent_files >= (limit or 10) then break end
end
::continue::
end
return recent_files
end
local function pattern_to_lua(pattern)
local lua_pattern = pattern:gsub("[%(%)%.%%%+%-%*%?%[%]%^%$]", "%%%1")
lua_pattern = lua_pattern:gsub("%*%*/", ".-/")
lua_pattern = lua_pattern:gsub("%*", "[^/]*")
lua_pattern = lua_pattern:gsub("%?", ".")
if lua_pattern:sub(-1) == "/" then lua_pattern = lua_pattern .. ".*" end
return lua_pattern
end
function M.parse_gitignore(gitignore_path)
local ignore_patterns = {}
local negate_patterns = {}
local file = io.open(gitignore_path, "r")
if not file then return ignore_patterns, negate_patterns end
for line in file:lines() do
if line:match("%S") and not line:match("^#") then
local trimmed_line = line:match("^%s*(.-)%s*$")
if trimmed_line:sub(1, 1) == "!" then
table.insert(negate_patterns, pattern_to_lua(trimmed_line:sub(2)))
else
table.insert(ignore_patterns, pattern_to_lua(trimmed_line))
end
end
end
file:close()
ignore_patterns = vim.list_extend(ignore_patterns, { "%.git", "%.worktree", "__pycache__", "node_modules" })
return ignore_patterns, negate_patterns
end
function M.is_ignored(file, ignore_patterns, negate_patterns)
for _, pattern in ipairs(negate_patterns) do
if file:match(pattern) then return false end
end
for _, pattern in ipairs(ignore_patterns) do
if file:match(pattern) then return true end
end
return false
end
---@param options { directory: string, add_dirs?: boolean, max_depth?: integer }
---@return string[]
function M.scan_directory(options)
local cmd_supports_max_depth = true
local cmd = (function()
if vim.fn.executable("rg") == 1 then
local cmd = { "rg", "--files", "--color", "never", "--no-require-git" }
if options.max_depth ~= nil then vim.list_extend(cmd, { "--max-depth", options.max_depth }) end
table.insert(cmd, options.directory)
return cmd
end
if vim.fn.executable("fd") == 1 then
local cmd = { "fd", "--type", "f", "--color", "never", "--no-require-git" }
if options.max_depth ~= nil then vim.list_extend(cmd, { "--max-depth", options.max_depth }) end
vim.list_extend(cmd, { "--base-directory", options.directory })
return cmd
end
if vim.fn.executable("fdfind") == 1 then
local cmd = { "fdfind", "--type", "f", "--color", "never", "--no-require-git" }
if options.max_depth ~= nil then vim.list_extend(cmd, { "--max-depth", options.max_depth }) end
vim.list_extend(cmd, { "--base-directory", options.directory })
return cmd
end
end)()
if not cmd then
if M.path_exists(M.join_paths(options.directory, ".git")) and vim.fn.executable("git") == 1 then
if vim.fn.has("win32") == 1 then
cmd = {
"powershell",
"-NoProfile",
"-NonInteractive",
"-Command",
string.format(
"Push-Location '%s'; (git ls-files --exclude-standard), (git ls-files --exclude-standard --others)",
options.directory:gsub("/", "\\")
),
}
else
cmd = {
"bash",
"-c",
string.format("cd %s && git ls-files -co --exclude-standard", options.directory),
}
end
cmd_supports_max_depth = false
else
M.error("No search command found")
return {}
end
end
local files = vim.fn.systemlist(cmd)
files = vim
.iter(files)
:map(function(file)
if not M.is_absolute_path(file) then return M.join_paths(options.directory, file) end
return file
end)
:totable()
if options.max_depth ~= nil and not cmd_supports_max_depth then
files = vim
.iter(files)
:filter(function(file)
local base_dir = options.directory
if base_dir:sub(-2) == "/." then base_dir = base_dir:sub(1, -3) end
local rel_path = M.make_relative_path(file, base_dir)
local pieces = vim.split(rel_path, "/")
return #pieces <= options.max_depth
end)
:totable()
end
if options.add_dirs then
local dirs = {}
local dirs_seen = {}
for _, file in ipairs(files) do
local dir = M.get_parent_path(file)
if not dirs_seen[dir] then
table.insert(dirs, dir)
dirs_seen[dir] = true
end
end
files = vim.list_extend(dirs, files)
end
return files
end
function M.get_parent_path(filepath)
if filepath == nil then error("filepath cannot be nil") end
if filepath == "" then return "" end
local is_abs = M.is_absolute_path(filepath)
if filepath:sub(-1) == M.path_sep then filepath = filepath:sub(1, -2) end
if filepath == "" then return "" end
local parts = vim.split(filepath, M.path_sep)
local parent_parts = vim.list_slice(parts, 1, #parts - 1)
local res = table.concat(parent_parts, M.path_sep)
if res == "" then
if is_abs then return M.path_sep end
return "."
end
return res
end
function M.make_relative_path(filepath, base_dir)
if filepath:sub(-2) == M.path_sep .. "." then filepath = filepath:sub(1, -3) end
if base_dir:sub(-2) == M.path_sep .. "." then base_dir = base_dir:sub(1, -3) end
if filepath == base_dir then return "." end
if filepath:sub(1, #base_dir) == base_dir then
filepath = filepath:sub(#base_dir + 1)
if filepath:sub(1, 2) == "." .. M.path_sep then
filepath = filepath:sub(3)
elseif filepath:sub(1, 1) == M.path_sep then
filepath = filepath:sub(2)
end
end
return filepath
end
function M.is_absolute_path(path)
if not path then return false end
if M.is_win() then return path:match("^%a:[/\\]") ~= nil end
return path:match("^/") ~= nil
end
function M.join_paths(...)
local paths = { ... }
local result = paths[1] or ""
for i = 2, #paths do
local path = paths[i]
if path == nil or path == "" then goto continue end
if M.is_absolute_path(path) then
result = path
goto continue
end
if path:sub(1, 2) == "." .. M.path_sep then path = path:sub(3) end
if result ~= "" and result:sub(-1) ~= M.path_sep then result = result .. M.path_sep end
result = result .. path
::continue::
end
return result
end
function M.path_exists(path) return vim.loop.fs_stat(path) ~= nil end
function M.is_first_letter_uppercase(str) return string.match(str, "^[A-Z]") ~= nil end
---@param content string
---@return { new_content: string, enable_project_context: boolean, enable_diagnostics: boolean }
function M.extract_mentions(content)
-- if content contains @codebase, enable project context and remove @codebase
local new_content = content
local enable_project_context = false
local enable_diagnostics = false
if content:match("@codebase") then
enable_project_context = true
new_content = content:gsub("@codebase", "")
end
if content:match("@diagnostics") then enable_diagnostics = true end
return {
new_content = new_content,
enable_project_context = enable_project_context,
enable_diagnostics = enable_diagnostics,
}
end
---@alias AvanteMentions "codebase" | "diagnostics"
---@alias AvanteMentionCallback fun(args: string, cb?: fun(args: string): nil): nil
---@alias AvanteMention {description: string, command: AvanteMentions, details: string, shorthelp?: string, callback?: AvanteMentionCallback}
---@return AvanteMention[]
function M.get_mentions()
return {
{
description = "codebase",
command = "codebase",
details = "repo map",
},
{
description = "diagnostics",
command = "diagnostics",
details = "diagnostics",
},
}
end
---@param filepath string
---@return integer|nil bufnr
local function get_opened_buffer_by_filepath(filepath)
local project_root = M.get_project_root()
local absolute_path = M.join_paths(project_root, filepath)
for _, buf in ipairs(api.nvim_list_bufs()) do
if M.join_paths(project_root, fn.bufname(buf)) == absolute_path then return buf end
end
return nil
end
---@param filepath string
---@return integer bufnr
function M.get_or_create_buffer_with_filepath(filepath)
-- Check if a buffer with this filepath already exists
local existing_buf = get_opened_buffer_by_filepath(filepath)
if existing_buf then
-- goto this buffer
api.nvim_set_current_buf(existing_buf)
return existing_buf
end
-- Create a new buffer without setting its name
local buf = api.nvim_create_buf(true, false)
-- Set the buffer options
api.nvim_set_option_value("buftype", "", { buf = buf })
-- Set the current buffer to the new buffer
api.nvim_set_current_buf(buf)
-- Use the edit command to load the file content and set the buffer name
vim.cmd("edit " .. fn.fnameescape(filepath))
return buf
end
---@param bufnr integer
---@param new_lines string[]
---@return { start_line: integer, end_line: integer, content: string[] }[]
local function get_buffer_content_diffs(bufnr, new_lines)
local old_lines = api.nvim_buf_get_lines(bufnr, 0, -1, false)
local diffs = {}
local prev_diff_idx = nil
for i, line in ipairs(new_lines) do
if line ~= old_lines[i] then
if prev_diff_idx == nil then prev_diff_idx = i end
else
if prev_diff_idx ~= nil then
local content = vim.list_slice(new_lines, prev_diff_idx, i - 1)
table.insert(diffs, { start_line = prev_diff_idx, end_line = i, content = content })
prev_diff_idx = nil
end
end
end
if prev_diff_idx ~= nil then
table.insert(
diffs,
{ start_line = prev_diff_idx, end_line = #new_lines + 1, content = vim.list_slice(new_lines, prev_diff_idx) }
)
end
if #new_lines < #old_lines then
table.insert(diffs, { start_line = #new_lines + 1, end_line = #old_lines + 1, content = {} })
end
table.sort(diffs, function(a, b) return a.start_line > b.start_line end)
return diffs
end
--- Update the buffer content more efficiently by only updating the changed lines
---@param bufnr integer
---@param new_lines string[]
function M.update_buffer_content(bufnr, new_lines)
local diffs = get_buffer_content_diffs(bufnr, new_lines)
if #diffs == 0 then return end
for _, diff in ipairs(diffs) do
api.nvim_buf_set_lines(bufnr, diff.start_line - 1, diff.end_line - 1, false, diff.content)
end
end
local severity = {
[1] = "ERROR",
[2] = "WARNING",
[3] = "INFORMATION",
[4] = "HINT",
}
---@class AvanteDiagnostic
---@field content string
---@field start_line number
---@field end_line number
---@field severity string
---@field source string
---@param bufnr integer
---@return AvanteDiagnostic[]
function M.get_diagnostics(bufnr)
if bufnr == nil then bufnr = api.nvim_get_current_buf() end
local diagnositcs = ---@type vim.Diagnostic[]
vim.diagnostic.get(bufnr, {
severity = {
vim.diagnostic.severity.ERROR,
vim.diagnostic.severity.WARN,
vim.diagnostic.severity.INFO,
vim.diagnostic.severity.HINT,
},
})
return vim
.iter(diagnositcs)
:map(function(diagnostic)
local d = {
content = diagnostic.message,
start_line = diagnostic.lnum + 1,
end_line = diagnostic.end_lnum and diagnostic.end_lnum + 1 or diagnostic.lnum + 1,
severity = severity[diagnostic.severity],
source = diagnostic.source,
}
return d
end)
:totable()
end
---@param bufnr integer
---@param selection avante.SelectionResult
function M.get_current_selection_diagnostics(bufnr, selection)
local diagnostics = M.get_diagnostics(bufnr)
local selection_diagnostics = {}
for _, diagnostic in ipairs(diagnostics) do
if selection.range.start.lnum <= diagnostic.start_line and selection.range.finish.lnum >= diagnostic.end_line then
table.insert(selection_diagnostics, diagnostic)
end
end
return selection_diagnostics
end
function M.uniform_path(path)
if type(path) ~= "string" then path = tostring(path) end
if not M.file.is_in_cwd(path) then return path end
local project_root = M.get_project_root()
local abs_path = M.is_absolute_path(path) and path or M.join_paths(project_root, path)
local relative_path = M.make_relative_path(abs_path, project_root)
return relative_path
end
function M.is_same_file(filepath_a, filepath_b) return M.uniform_path(filepath_a) == M.uniform_path(filepath_b) end
function M.trim_think_content(content) return content:gsub("^.-", "", 1) end
function M.get_filetype(filepath)
-- Some files are sometimes not detected correctly when buffer is not included
-- https://github.com/neovim/neovim/issues/27265
local buf = vim.api.nvim_create_buf(false, true)
local filetype = vim.filetype.match({ filename = filepath, buf = buf }) or ""
vim.api.nvim_buf_delete(buf, { force = true })
return filetype
end
---@param filepath string
---@return string[]|nil lines
---@return string|nil error
function M.read_file_from_buf_or_disk(filepath)
--- Lookup if the file is loaded in a buffer
local bufnr = vim.fn.bufnr(filepath)
if bufnr ~= -1 and vim.api.nvim_buf_is_loaded(bufnr) then
-- If buffer exists and is loaded, get buffer content
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
return lines, nil
end
-- Fallback: read file from disk
local file, open_err = io.open(filepath, "r")
if file then
local content = file:read("*all")
file:close()
content = content:gsub("\r\n", "\n")
return vim.split(content, "\n"), nil
else
return {}, open_err
end
end
---Check if an icon plugin is installed
---@return boolean
function M.icons_enabled() return M.has("nvim-web-devicons") or M.has("mini.icons") or M.has("mini.nvim") end
---Display an string with icon, if an icon plugin is available.
---Dev icons are an optional install for avante, this function prevents ugly chars
---being displayed by displaying fallback options or nothing at all.
---@param string_with_icon string
---@param utf8_fallback string|nil
---@return string
function M.icon(string_with_icon, utf8_fallback)
if M.icons_enabled() then
return string_with_icon
else
return utf8_fallback or ""
end
end
function M.deep_extend_with_metatable(behavior, ...)
local tables = { ... }
local base = tables[1]
if behavior == "keep" then base = tables[#tables] end
local mt = getmetatable(base)
local result = vim.tbl_deep_extend(behavior, ...)
if mt then setmetatable(result, mt) end
return result
end
function M.utc_now()
local utc_date = os.date("!*t")
---@diagnostic disable-next-line: param-type-mismatch
local utc_time = os.time(utc_date)
return os.date("%Y-%m-%d %H:%M:%S", utc_time)
end
---@param dt1 string
---@param dt2 string
---@return integer delta_seconds
function M.datetime_diff(dt1, dt2)
local pattern = "(%d+)-(%d+)-(%d+) (%d+):(%d+):(%d+)"
local y1, m1, d1, h1, min1, s1 = dt1:match(pattern)
local y2, m2, d2, h2, min2, s2 = dt2:match(pattern)
local time1 = os.time({ year = y1, month = m1, day = d1, hour = h1, min = min1, sec = s1 })
local time2 = os.time({ year = y2, month = m2, day = d2, hour = h2, min = min2, sec = s2 })
local delta_seconds = os.difftime(time2, time1)
return delta_seconds
end
---@param iso_str string
---@return string|nil
---@return string|nil error
function M.parse_iso8601_date(iso_str)
local year, month, day, hour, min, sec = iso_str:match("(%d+)-(%d+)-(%d+)T(%d+):(%d+):(%d+)Z")
if not year then return nil, "Invalid ISO 8601 format" end
local time_table = {
year = tonumber(year),
month = tonumber(month),
day = tonumber(day),
hour = tonumber(hour),
min = tonumber(min),
sec = tonumber(sec),
isdst = false,
}
local timestamp = os.time(time_table)
return tostring(os.date("%Y-%m-%d %H:%M:%S", timestamp)), nil
end
function M.random_string(length)
local charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
local result = {}
for _ = 1, length do
local rand = math.random(1, #charset)
table.insert(result, charset:sub(rand, rand))
end
return table.concat(result)
end
function M.is_left_adjacent(win_a, win_b)
if not vim.api.nvim_win_is_valid(win_a) or not vim.api.nvim_win_is_valid(win_b) then return false end
local _, col_a = unpack(vim.fn.win_screenpos(win_a))
local _, col_b = unpack(vim.fn.win_screenpos(win_b))
local width_a = vim.api.nvim_win_get_width(win_a)
local right_edge_a = col_a + width_a
return right_edge_a + 1 == col_b
end
function M.is_top_adjacent(win_a, win_b)
local row_a, _ = unpack(vim.fn.win_screenpos(win_a))
local row_b, _ = unpack(vim.fn.win_screenpos(win_b))
local height_a = vim.api.nvim_win_get_height(win_a)
return row_a + height_a + 1 == row_b
end
function M.should_hidden_border(win_a, win_b)
return M.is_left_adjacent(win_a, win_b) or M.is_top_adjacent(win_a, win_b)
end
---@param fields AvanteLLMToolParamField[]
---@return table[] properties
---@return string[] required
function M.llm_tool_param_fields_to_json_schema(fields)
local properties = {}
local required = {}
for _, field in ipairs(fields) do
if field.type == "object" and field.fields then
local properties_, required_ = M.llm_tool_param_fields_to_json_schema(field.fields)
properties[field.name] = {
type = field.type,
description = field.get_description and field.get_description() or field.description,
properties = properties_,
required = required_,
}
else
properties[field.name] = {
type = field.type,
description = field.get_description and field.get_description() or field.description,
}
end
if not field.optional then table.insert(required, field.name) end
end
return properties, required
end
---@return AvanteSlashCommand[]
function M.get_commands()
local Config = require("avante.config")
---@param items_ {name: string, description: string, shorthelp?: string}[]
---@return string
local function get_help_text(items_)
local help_text = ""
for _, item in ipairs(items_) do
help_text = help_text .. "- " .. item.name .. ": " .. (item.shorthelp or item.description) .. "\n"
end
return help_text
end
local builtin_items = {
{ description = "Show help message", name = "help" },
{ description = "Clear chat history", name = "clear" },
{ description = "Reset memory", name = "reset" },
{ description = "New chat", name = "new" },
{
shorthelp = "Ask a question about specific lines",
description = "/lines - ",
name = "lines",
},
{ description = "Commit the changes", name = "commit" },
}
---@type {[AvanteSlashCommandBuiltInName]: AvanteSlashCommandCallback}
local builtin_cbs = {
help = function(sidebar, args, cb)
local help_text = get_help_text(builtin_items)
sidebar:update_content(help_text, { focus = false, scroll = false })
if cb then cb(args) end
end,
clear = function(sidebar, args, cb) sidebar:clear_history(args, cb) end,
reset = function(sidebar, args, cb) sidebar:reset_memory(args, cb) end,
new = function(sidebar, args, cb) sidebar:new_chat(args, cb) end,
lines = function(_, args, cb)
if cb then cb(args) end
end,
commit = function(_, _, cb)
local question = "Please commit the changes"
if cb then cb(question) end
end,
}
local builtin_commands = vim
.iter(builtin_items)
:map(
---@param item AvanteSlashCommand
function(item)
return {
name = item.name,
description = item.description,
callback = builtin_cbs[item.name],
details = item.shorthelp and table.concat({ item.shorthelp, item.description }, "\n") or item.description,
}
end
)
:totable()
return vim.list_extend(builtin_commands, Config.slash_commands)
end
return M