349 lines
9.8 KiB
Lua
349 lines
9.8 KiB
Lua
---@mod codetyper.utils Utility functions for Codetyper.nvim
|
|
|
|
local M = {}
|
|
|
|
--- Generate a unique ID
|
|
---@param prefix? string Prefix for the ID (default: "id")
|
|
---@return string Unique ID
|
|
function M.generate_id(prefix)
|
|
prefix = prefix or "id"
|
|
return prefix .. "_" .. string.format("%x", os.time()) .. "_" .. string.format("%x", math.random(0, 0xFFFF))
|
|
end
|
|
|
|
--- Get the project root directory
|
|
---@return string|nil Root directory path or nil if not found
|
|
function M.get_project_root()
|
|
-- Try to find common root indicators
|
|
local markers = { ".git", ".gitignore", "package.json", "Cargo.toml", "go.mod", "pyproject.toml" }
|
|
|
|
local current = vim.fn.getcwd()
|
|
|
|
for _, marker in ipairs(markers) do
|
|
local found = vim.fn.findfile(marker, current .. ";")
|
|
if found ~= "" then
|
|
return vim.fn.fnamemodify(found, ":p:h")
|
|
end
|
|
found = vim.fn.finddir(marker, current .. ";")
|
|
if found ~= "" then
|
|
-- For directories, :p:h gives the dir itself, so we need :p:h:h to get parent
|
|
return vim.fn.fnamemodify(found, ":p:h:h")
|
|
end
|
|
end
|
|
|
|
return current
|
|
end
|
|
|
|
--- Check if current working directory IS a git repository root
|
|
--- Only returns true if .git folder exists directly in cwd (not in parent)
|
|
---@return boolean
|
|
function M.is_git_project()
|
|
local cwd = vim.fn.getcwd()
|
|
local git_path = cwd .. "/.git"
|
|
-- Check if .git exists as a directory or file (for worktrees)
|
|
return vim.fn.isdirectory(git_path) == 1 or vim.fn.filereadable(git_path) == 1
|
|
end
|
|
|
|
--- Get git root directory (only if cwd is a git root)
|
|
---@return string|nil Git root or nil if not a git project
|
|
function M.get_git_root()
|
|
if M.is_git_project() then
|
|
return vim.fn.getcwd()
|
|
end
|
|
return nil
|
|
end
|
|
|
|
--- Check if a file is a coder file
|
|
---@param filepath string File path to check
|
|
---@return boolean
|
|
function M.is_coder_file(filepath)
|
|
return filepath:match("%.coder%.") ~= nil
|
|
end
|
|
|
|
--- Get the target file path from a coder file path
|
|
---@param coder_path string Path to the coder file
|
|
---@return string Target file path
|
|
function M.get_target_path(coder_path)
|
|
-- Convert index.coder.ts -> index.ts
|
|
return coder_path:gsub("%.coder%.", ".")
|
|
end
|
|
|
|
--- Get the coder file path from a target file path
|
|
---@param target_path string Path to the target file
|
|
---@return string Coder file path
|
|
function M.get_coder_path(target_path)
|
|
-- Convert index.ts -> index.coder.ts
|
|
local dir = vim.fn.fnamemodify(target_path, ":h")
|
|
local name = vim.fn.fnamemodify(target_path, ":t:r")
|
|
local ext = vim.fn.fnamemodify(target_path, ":e")
|
|
|
|
if dir == "." then
|
|
return name .. ".coder." .. ext
|
|
end
|
|
return dir .. "/" .. name .. ".coder." .. ext
|
|
end
|
|
|
|
--- Check if a file exists
|
|
---@param filepath string File path to check
|
|
---@return boolean
|
|
function M.file_exists(filepath)
|
|
local stat = vim.loop.fs_stat(filepath)
|
|
return stat ~= nil
|
|
end
|
|
|
|
--- Read file contents
|
|
---@param filepath string File path to read
|
|
---@return string|nil Contents or nil if error
|
|
function M.read_file(filepath)
|
|
local file = io.open(filepath, "r")
|
|
if not file then
|
|
return nil
|
|
end
|
|
local content = file:read("*all")
|
|
file:close()
|
|
return content
|
|
end
|
|
|
|
--- Write content to file
|
|
---@param filepath string File path to write
|
|
---@param content string Content to write
|
|
---@return boolean Success status
|
|
function M.write_file(filepath, content)
|
|
local file = io.open(filepath, "w")
|
|
if not file then
|
|
return false
|
|
end
|
|
file:write(content)
|
|
file:close()
|
|
return true
|
|
end
|
|
|
|
--- Create directory if it doesn't exist
|
|
---@param dirpath string Directory path
|
|
---@return boolean Success status
|
|
function M.ensure_dir(dirpath)
|
|
if vim.fn.isdirectory(dirpath) == 0 then
|
|
return vim.fn.mkdir(dirpath, "p") == 1
|
|
end
|
|
return true
|
|
end
|
|
|
|
--- Notify user with proper formatting
|
|
---@param msg string Message to display
|
|
---@param level? number Vim log level (default: INFO)
|
|
function M.notify(msg, level)
|
|
level = level or vim.log.levels.INFO
|
|
vim.notify("[Codetyper] " .. msg, level)
|
|
end
|
|
|
|
--- Get buffer filetype
|
|
---@param bufnr? number Buffer number (default: current)
|
|
---@return string Filetype
|
|
function M.get_filetype(bufnr)
|
|
bufnr = bufnr or 0
|
|
return vim.bo[bufnr].filetype
|
|
end
|
|
|
|
--- Escape pattern special characters
|
|
---@param str string String to escape
|
|
---@return string Escaped string
|
|
function M.escape_pattern(str)
|
|
return str:gsub("([%(%)%.%%%+%-%*%?%[%]%^%$])", "%%%1")
|
|
end
|
|
|
|
--- Get visual selection text
|
|
--- Call this BEFORE leaving visual mode or use marks '< and '>
|
|
---@return table|nil Selection info {text: string, start_line: number, end_line: number, filepath: string} or nil
|
|
function M.get_visual_selection()
|
|
local mode = vim.fn.mode()
|
|
|
|
-- Get marks - works in visual mode or after visual selection
|
|
local start_line = vim.fn.line("'<")
|
|
local end_line = vim.fn.line("'>")
|
|
local start_col = vim.fn.col("'<")
|
|
local end_col = vim.fn.col("'>")
|
|
|
|
-- If marks are not set (both 0), return nil
|
|
if start_line == 0 and end_line == 0 then
|
|
return nil
|
|
end
|
|
|
|
local bufnr = vim.api.nvim_get_current_buf()
|
|
local lines = vim.api.nvim_buf_get_lines(bufnr, start_line - 1, end_line, false)
|
|
|
|
if #lines == 0 then
|
|
return nil
|
|
end
|
|
|
|
-- Handle visual line mode - get full lines
|
|
local text
|
|
if mode == "V" or mode == "\22" then -- Visual line or Visual block
|
|
text = table.concat(lines, "\n")
|
|
else
|
|
-- Character-wise visual mode - trim first and last line
|
|
if #lines == 1 then
|
|
text = lines[1]:sub(start_col, end_col)
|
|
else
|
|
lines[1] = lines[1]:sub(start_col)
|
|
lines[#lines] = lines[#lines]:sub(1, end_col)
|
|
text = table.concat(lines, "\n")
|
|
end
|
|
end
|
|
|
|
local filepath = vim.fn.expand("%:p")
|
|
local filename = vim.fn.expand("%:t")
|
|
|
|
return {
|
|
text = text,
|
|
start_line = start_line,
|
|
end_line = end_line,
|
|
filepath = filepath,
|
|
filename = filename,
|
|
language = vim.bo[bufnr].filetype,
|
|
}
|
|
end
|
|
|
|
--- Check bracket balance for common languages
|
|
---@param response string
|
|
---@return boolean balanced
|
|
function M.check_brackets(response)
|
|
local pairs = {
|
|
["{"] = "}",
|
|
["["] = "]",
|
|
["("] = ")",
|
|
}
|
|
|
|
local stack = {}
|
|
|
|
for char in response:gmatch(".") do
|
|
if pairs[char] then
|
|
table.insert(stack, pairs[char])
|
|
elseif char == "}" or char == "]" or char == ")" then
|
|
if #stack == 0 or stack[#stack] ~= char then
|
|
return false
|
|
end
|
|
table.remove(stack)
|
|
end
|
|
end
|
|
|
|
return #stack == 0
|
|
end
|
|
|
|
--- Simple hash function for content
|
|
---@param content string
|
|
---@return string
|
|
function M.hash_content(content)
|
|
local hash = vim.fn.sha256(content)
|
|
-- If sha256 returns hex string, format %x might be wrong if it expects number?
|
|
-- vim.fn.sha256 returns a hex string already.
|
|
return hash
|
|
end
|
|
|
|
--- Check if a line is empty or a comment
|
|
|
|
---@param line string
|
|
---@param filetype string
|
|
---@return boolean
|
|
function M.is_empty_or_comment(line, filetype)
|
|
local trimmed = line:match("^%s*(.-)%s*$")
|
|
if trimmed == "" then
|
|
return true
|
|
end
|
|
|
|
local ok, languages = pcall(require, "codetyper.params.agent.languages")
|
|
if not ok then return false end
|
|
|
|
local patterns = languages.comment_patterns[filetype] or languages.comment_patterns.javascript
|
|
for _, pattern in ipairs(patterns) do
|
|
if trimmed:match(pattern) then
|
|
return true
|
|
end
|
|
end
|
|
|
|
return false
|
|
end
|
|
|
|
--- Classify an import as "builtin", "local", or "third_party"
|
|
---@param imp string The import statement
|
|
---@param filetype string The filetype
|
|
---@return string category "builtin"|"local"|"third_party"
|
|
function M.classify_import(imp, filetype)
|
|
local is_local = false
|
|
local is_builtin = false
|
|
|
|
if filetype == "javascript" or filetype == "typescript" or filetype == "ts" or filetype == "tsx" then
|
|
-- Local: starts with . or ..
|
|
is_local = imp:match("from%s+['\"]%.") or imp:match("require%(['\"]%.")
|
|
-- Node builtin modules
|
|
is_builtin = imp:match("from%s+['\"]node:") or imp:match("from%s+['\"]fs['\"]")
|
|
or imp:match("from%s+['\"]path['\"]") or imp:match("from%s+['\"]http['\"]")
|
|
elseif filetype == "python" or filetype == "py" then
|
|
-- Local: relative imports
|
|
is_local = imp:match("^from%s+%.") or imp:match("^import%s+%.")
|
|
-- Python stdlib (simplified check)
|
|
is_builtin = imp:match("^import%s+os") or imp:match("^import%s+sys")
|
|
or imp:match("^from%s+os%s+") or imp:match("^from%s+sys%s+")
|
|
or imp:match("^import%s+re") or imp:match("^import%s+json")
|
|
elseif filetype == "lua" then
|
|
-- Local: relative requires
|
|
is_local = imp:match("require%(['\"]%.") or imp:match("require%s+['\"]%.")
|
|
elseif filetype == "go" then
|
|
-- Local: project imports (contain /)
|
|
is_local = imp:match("['\"][^'\"]+/[^'\"]+['\"]") and not imp:match("github%.com")
|
|
end
|
|
|
|
if is_builtin then
|
|
return "builtin"
|
|
elseif is_local then
|
|
return "local"
|
|
else
|
|
return "third_party"
|
|
end
|
|
end
|
|
|
|
--- Check if a line ends a multi-line import
|
|
---@param line string
|
|
---@param filetype string
|
|
---@return boolean
|
|
function M.ends_multiline_import(line, filetype)
|
|
-- Check for closing patterns
|
|
if filetype == "javascript" or filetype == "typescript" or filetype == "ts" or filetype == "tsx" then
|
|
-- ES6 imports end with 'from "..." ;' or just ';' or a line with just '}'
|
|
if line:match("from%s+['\"][^'\"]+['\"]%s*;?%s*$") then
|
|
return true
|
|
end
|
|
if line:match("}%s*from%s+['\"]") then
|
|
return true
|
|
end
|
|
if line:match("^%s*}%s*;?%s*$") then
|
|
return true
|
|
end
|
|
if line:match(";%s*$") then
|
|
return true
|
|
end
|
|
elseif filetype == "python" or filetype == "py" then
|
|
-- Python single-line import: doesn't end with \, (, or ,
|
|
-- Examples: "from typing import List, Dict" or "import os"
|
|
if not line:match("\\%s*$") and not line:match("%(%s*$") and not line:match(",%s*$") then
|
|
return true
|
|
end
|
|
-- Python multiline imports end with closing paren
|
|
if line:match("%)%s*$") then
|
|
return true
|
|
end
|
|
elseif filetype == "go" then
|
|
-- Go multi-line imports end with ')'
|
|
if line:match("%)%s*$") then
|
|
return true
|
|
end
|
|
elseif filetype == "rust" or filetype == "rs" then
|
|
-- Rust use statements end with ';'
|
|
if line:match(";%s*$") then
|
|
return true
|
|
end
|
|
end
|
|
|
|
return false
|
|
end
|
|
|
|
return M
|