Files
gitlab.nvim/lua/gitlab/utils/init.lua
johnybx 02db3e4b0e Feat: Sort Discussions by File Name (#102)
This MR adds the ability to sort discussions by file name, rather than just by date.

This is an optional configuration that can be passed in on startup. The MR also introduces a test suite for the Lua code that runs through Neovim, so that the plugin can be fully tested with required dependencies and APIs.

Major props to @johnybx for the hard work on this change!
2023-12-04 17:03:32 -05:00

621 lines
18 KiB
Lua

local has_devicons, devicons = pcall(require, "nvim-web-devicons")
local M = {}
---Pulls out a list of values matching a given key from an array of tables
---@param t table List of tables to search
---@param key string Value to search for in the list
---@return table List List of values that were extracted
M.extract = function(t, key)
local resultTable = {}
for _, value in ipairs(t) do
if value[key] then
table.insert(resultTable, value[key])
end
end
return resultTable
end
---Get the last word in a sentence
---@param sentence string The string to get the last word from
---@param divider string The regex to split the sentence by, defaults to whitespace
---@return string
M.get_last_word = function(sentence, divider)
local words = {}
local pattern = string.format("([^%s]+)", divider or " ")
for word in sentence:gmatch(pattern) do
table.insert(words, word)
end
return words[#words] or ""
end
---Merges two deeply nested tables together, overriding values from the first with conflicts
---@param defaults table The first table
---@param overrides table The second table
---@return table
M.merge = function(defaults, overrides)
if type(defaults) == "table" and M.table_size(defaults) == 0 and type(overrides) == "table" then
return overrides
end
return vim.tbl_deep_extend("force", defaults, overrides)
end
---Pluralizes the input word, e.g. "3 cows"
---@param num integer The count of the item/word
---@param word string The word to pluralize
---@return string
local function pluralize(num, word)
return num .. string.format(" %s", word) .. ((num > 1 or num <= 0) and "s" or "")
end
--- Provides a human readable time since a given ISO date string
---@param date_string string -- The ISO time stamp to compare with the current time
---@return string
M.time_since = function(date_string, current_date_table)
local dt = current_date_table or os.date("!*t")
local year, month, day, hour, min, sec = date_string:match("(%d+)-(%d+)-(%d+)T(%d+):(%d+):(%d+)")
local date = os.time({ year = year, month = month, day = day, hour = hour, min = min, sec = sec })
local current_date = os.time({
year = dt.year,
month = dt.month,
day = dt.day,
hour = dt.hour,
min = dt.min,
sec = dt.sec,
})
local time_diff = current_date - date
if time_diff < 60 then
return pluralize(time_diff, "second") .. " ago"
elseif time_diff < 3600 then
return pluralize(math.floor(time_diff / 60), "minute") .. " ago"
elseif time_diff < 86400 then
return pluralize(math.floor(time_diff / 3600), "hour") .. " ago"
elseif time_diff < 2592000 then
return pluralize(math.floor(time_diff / 86400), "day") .. " ago"
else
local formatted_date = os.date("%B %e, %Y", date)
return tostring(formatted_date)
end
end
---Removes the first value from a list and returns the new, smaller list
---@param tbl table The table
---@return table
M.remove_first_value = function(tbl)
local sliced_list = {}
if M.table_size(tbl) <= 1 then
return sliced_list
end
for i = 2, #tbl do
table.insert(sliced_list, tbl[i])
end
return sliced_list
end
---Spreads all the values from t2 into t1
---@param t1 table The first table (gets the values)
---@param t2 table The second table
---@return table
M.spread = function(t1, t2)
for _, value in ipairs(t2) do
table.insert(t1, value)
end
return t1
end
---Returns the number of keys or values in a table
---@param t table The table to count
---@return integer
M.table_size = function(t)
local count = 0
for _ in pairs(t) do
count = count + 1
end
return count
end
---Returns whether a given value is in a list or not
---@param list table The list to search
---@return boolean
M.contains = function(list, search_value)
for _, value in pairs(list) do
if value == search_value then
return true
end
end
return false
end
---Trims whitespace from a string
---@param s string The string to trim
---@return string
M.trim = function(s)
local res = s:gsub("^%s+", ""):gsub("%s+$", "")
return res
end
-- Reverses the order of elements in a list
---@param list table The list to reverse
---@return table
M.reverse = function(list)
if #list == 0 then
return list
end
local rev = {}
for i = #list, 1, -1 do
rev[#rev + 1] = list[i]
end
return rev
end
---Returns the difference between a time offset and UTC time, in seconds
---@param offset string The offset to compare, e.g. -0500 for EST
---@return number
M.offset_to_seconds = function(offset)
local sign, hours, minutes = offset:match("([%+%-])(%d%d)(%d%d)")
local offset_in_seconds = tonumber(hours) * 3600 + tonumber(minutes) * 60
if sign == "-" then
offset_in_seconds = -offset_in_seconds
end
return offset_in_seconds
end
---Converts a UTC timestamp and offset to a human readable datestring
---@param date_string string The time stamp
---@param offset string The offset of the user's local time zone, e.g. -0500 for EST
---@return string
M.format_to_local = function(date_string, offset)
-- ISO 8601 format
-- 2021-01-01T00:00:00.000Z
local year, month, day, hour, min, sec, _, tzOffset = date_string:match("(%d+)-(%d+)-(%d+)T(%d+):(%d+):(%d+).(%d+)Z")
if year == nil then
-- ISO 8601 format with timezone offset
-- 2021-01-01T00:00:00.000-05:00
local tzOffsetSign, tzOffsetHour, tzOffsetMin
year, month, day, hour, min, sec, _, tzOffsetSign, tzOffsetHour, tzOffsetMin =
date_string:match("(%d+)-(%d+)-(%d+)T(%d+):(%d+):(%d+).(%d+)([%+%-])(%d%d):(%d%d)")
tzOffset = tzOffsetSign .. tzOffsetHour .. tzOffsetMin
end
local localTime = os.time({
year = year,
month = month,
day = day,
hour = hour,
min = min,
sec = sec,
tzOffset = tzOffset,
})
-- Subtract the tzOffset from the local time to get the UTC time
local localTimestamp = tzOffset ~= nil and localTime - M.offset_to_seconds(tzOffset) or localTime
localTimestamp = localTimestamp + M.offset_to_seconds(offset)
return tostring(os.date("%m/%d/%Y at %H:%M", localTimestamp))
end
-- Returns a comma separated (human readable) list of values from a list of associative tables
---@param list_of_tables table The list to traverse
---@param key string The key of the values to pull from the tables
---@return string
M.make_readable_list = function(list_of_tables, key)
local res = ""
for i, t in ipairs(list_of_tables) do
res = res .. t[key]
if i < #list_of_tables then
res = res .. ", "
end
end
return res
end
-- Returns the length of the longest string in a list of strings
---@param list table The list of strings
---@return number
M.get_longest_string = function(list)
local longest = 0
for _, v in pairs(list) do
if string.len(v) > longest then
longest = string.len(v)
end
end
return longest
end
M.notify = function(msg, lvl)
vim.notify("gitlab.nvim: " .. msg, lvl)
end
M.get_colors_for_group = function(group)
local normal_fg = vim.fn.synIDattr(vim.fn.synIDtrans((vim.fn.hlID(group))), "fg")
local normal_bg = vim.fn.synIDattr(vim.fn.synIDtrans((vim.fn.hlID(group))), "bg")
return { fg = normal_fg, bg = normal_bg }
end
M.get_current_line_number = function()
return vim.api.nvim_call_function("line", { "." })
end
M.is_windows = function()
if vim.fn.has("win32") == 1 or vim.fn.has("win32unix") == 1 then
return true
end
return false
end
---Path separator based on current OS.
---@type string
M.path_separator = M.is_windows() and "\\" or "/"
---Split path by OS path separator.
---@param path string
---@return string[]
M.split_path = function(path)
local path_parts = {}
for part in string.gmatch(path, "([^" .. M.path_separator .. "]+)") do
table.insert(path_parts, part)
end
return path_parts
end
M.P = function(...)
local objects = {}
for i = 1, select("#", ...) do
local v = select(i, ...)
table.insert(objects, vim.inspect(v))
end
print(table.concat(objects, "\n"))
return ...
end
M.get_buffer_text = function(bufnr)
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
local text = table.concat(lines, "\n")
return text
end
M.string_starts = function(str, start)
return str:sub(1, #start) == start
end
M.press_enter = function()
vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("<CR>", false, true, true), "n", false)
end
---Return timestamp from ISO 8601 formatted date string.
---@param date_string string ISO 8601 formatted date string
---@return integer timestamp
M.from_iso_format_date_to_timestamp = function(date_string)
local year, month, day, hour, min, sec = date_string:match("(%d+)-(%d+)-(%d+)T(%d+):(%d+):(%d+)")
return os.time({ year = year, month = month, day = day, hour = hour, min = min, sec = sec })
end
M.format_date = function(date_string)
local date_table = os.date("!*t")
local date = M.from_iso_format_date_to_timestamp(date_string)
local current_date = os.time({
year = date_table.year,
month = date_table.month,
day = date_table.day,
hour = date_table.hour,
min = date_table.min,
sec = date_table.sec,
})
local time_diff = current_date - date
if time_diff < 60 then
return pluralize(time_diff, "second")
elseif time_diff < 3600 then
return pluralize(math.floor(time_diff / 60), "minute")
elseif time_diff < 86400 then
return pluralize(math.floor(time_diff / 3600), "hour")
elseif time_diff < 2592000 then
return pluralize(math.floor(time_diff / 86400), "day")
else
local formatted_date = os.date("%A, %B %e", date)
return formatted_date
end
end
M.jump_to_file = function(filename, line_number)
if line_number == nil then
line_number = 1
end
local bufnr = vim.fn.bufnr(filename)
if bufnr ~= -1 then
M.jump_to_buffer(bufnr, line_number)
return
end
-- If buffer is not already open, open it
vim.cmd("edit " .. filename)
vim.api.nvim_win_set_cursor(0, { line_number, 0 })
end
M.jump_to_buffer = function(bufnr, line_number)
vim.cmd("buffer " .. bufnr)
vim.api.nvim_win_set_cursor(0, { line_number, 0 })
end
M.create_popup_state = function(title, width, height)
return {
buf_options = {
filetype = "markdown",
},
relative = "editor",
enter = true,
focusable = true,
border = {
style = "rounded",
text = {
top = title,
},
},
position = "50%",
size = {
width = width,
height = height,
},
}
end
M.read_file = function(file_path)
local file = io.open(file_path, "r")
if file == nil then
return nil
end
local file_contents = file:read("*all")
file:close()
file_contents = string.gsub(file_contents, "\n", "")
return file_contents
end
M.current_file_path = function()
local path = debug.getinfo(1, "S").source:sub(2)
return vim.fn.fnamemodify(path, ":p")
end
local random = math.random
M.uuid = function()
local template = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx"
return string.gsub(template, "[xy]", function(c)
local v = (c == "x") and random(0, 0xf) or random(8, 0xb)
return string.format("%x", v)
end)
end
M.remove_last_chunk = function(sentence)
local words = {}
for word in sentence:gmatch("%S+") do
table.insert(words, word)
end
table.remove(words, #words)
local sentence_without_last = table.concat(words, " ")
return sentence_without_last
end
M.get_line_content = function(bufnr, start)
local current_buffer = vim.api.nvim_get_current_buf()
local lines = vim.api.nvim_buf_get_lines(bufnr ~= nil and bufnr or current_buffer, start - 1, start, false)
return lines[1]
end
M.get_win_from_buf = function(bufnr)
for _, win in ipairs(vim.api.nvim_list_wins()) do
if vim.fn.winbufnr(win) == bufnr then
return win
end
end
end
M.switch_can_edit_buf = function(buf, bool)
vim.api.nvim_set_option_value("modifiable", bool, { buf = buf })
vim.api.nvim_set_option_value("readonly", not bool, { buf = buf })
end
M.list_files_in_folder = function(folder_path)
if vim.fn.isdirectory(folder_path) == 0 then
return nil
end
local folder_ok, folder = pcall(vim.fn.readdir, folder_path)
if not folder_ok then
return nil
end
local files = {}
if folder ~= nil then
for _, file in ipairs(folder) do
local file_path = folder_path .. M.path_separator .. file
local timestamp = vim.fn.getftime(file_path)
table.insert(files, { name = file, timestamp = timestamp })
end
end
-- Sort the table by timestamp in descending order (newest first)
table.sort(files, function(a, b)
return a.timestamp > b.timestamp
end)
local result = {}
for _, file in ipairs(files) do
table.insert(result, file.name)
end
return result
end
---@class Hunk
---@field old_line integer
---@field old_range integer
---@field new_line integer
---@field new_range integer
---Parse git diff hunks.
---@param file_path string Path to file.
---@param base_branch string Git base branch of merge request.
---@return Hunk[] list of hunks.
M.parse_hunk_headers = function(file_path, base_branch)
local hunks = {}
local Job = require("plenary.job")
local diff_job = Job:new({
command = "git",
args = { "diff", "--minimal", "--unified=0", "--no-color", base_branch, "--", file_path },
on_exit = function(j, return_code)
if return_code == 0 then
for _, line in ipairs(j:result()) do
if line:sub(1, 2) == "@@" then
-- match:
-- @@ -23 +23 @@ ...
-- @@ -23,0 +23 @@ ...
-- @@ -41,0 +42,4 @@ ...
local old_start, old_range, new_start, new_range = line:match("@@+ %-(%d+),?(%d*) %+(%d+),?(%d*) @@+")
table.insert(hunks, {
old_line = tonumber(old_start),
old_range = tonumber(old_range) or 0,
new_line = tonumber(new_start),
new_range = tonumber(new_range) or 0,
})
end
end
else
M.notify("Failed to get git diff: " .. j:stderr(), vim.log.levels.WARN)
end
end,
})
diff_job:sync()
return hunks
end
---@class LineDiffInfo
---@field old_line integer
---@field new_line integer
---@field in_hunk boolean
---Search git diff hunks to find old and new line number corresponding to target line.
---This function does not check if target line is outside of boundaries of file.
---@param hunks Hunk[] git diff parsed hunks.
---@param target_line integer line number to search for - based on is_new paramter the search is
---either in new lines or old lines of hunks.
---@param is_new boolean whether to search for new line or old line
---@return LineDiffInfo
M.get_lines_from_hunks = function(hunks, target_line, is_new)
if #hunks == 0 then
-- If there are zero hunks, return target_line for both old and new lines
return { old_line = target_line, new_line = target_line, in_hunk = false }
end
local current_new_line = 0
local current_old_line = 0
if is_new then
for _, hunk in ipairs(hunks) do
-- target line is before current hunk
if target_line < hunk.new_line then
return {
old_line = current_old_line + (target_line - current_new_line),
new_line = target_line,
in_hunk = false,
}
-- target line is within the current hunk
elseif hunk.new_line <= target_line and target_line <= (hunk.new_line + hunk.new_range) then
-- this is interesting magic of gitlab calculation
return {
old_line = hunk.old_line + hunk.old_range + 1,
new_line = target_line,
in_hunk = true,
}
-- target line is after the current hunk
else
current_new_line = hunk.new_line + hunk.new_range
current_old_line = hunk.old_line + hunk.old_range
end
end
-- target line is after last hunk
return {
old_line = current_old_line + (target_line - current_new_line),
new_line = target_line,
in_hunk = false,
}
else
for _, hunk in ipairs(hunks) do
-- target line is before current hunk
if target_line < hunk.old_line then
return {
old_line = target_line,
new_line = current_new_line + (target_line - current_old_line),
in_hunk = false,
}
-- target line is within the current hunk
elseif hunk.old_line <= target_line and target_line <= (hunk.old_line + hunk.old_range) then
return {
old_line = target_line,
new_line = hunk.new_line,
in_hunk = true,
}
-- target line is after the current hunk
else
current_new_line = hunk.new_line + hunk.new_range
current_old_line = hunk.old_line + hunk.old_range
end
end
-- target line is after last hunk
return {
old_line = current_old_line + (target_line - current_new_line),
new_line = target_line,
in_hunk = false,
}
end
end
---Check if current mode is visual mode
---@return boolean is_visual true if current mode is visual mode
M.check_visual_mode = function()
local mode = vim.api.nvim_get_mode().mode
if mode ~= "v" and mode ~= "V" then
M.notify("Code suggestions are only available in visual mode", vim.log.levels.WARN)
return false
end
return true
end
---Return start line and end line of visual selection.
---Exists visual mode in order to access marks "<" , ">"
---@return integer start,integer end Start line and end line
M.get_visual_selection_boundaries = function()
vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("<Esc>", false, true, true), "nx", false)
local start_line = vim.api.nvim_buf_get_mark(0, "<")[1]
local end_line = vim.api.nvim_buf_get_mark(0, ">")[1]
return start_line, end_line
end
---Get icon for filename if nvim-web-devicons plugin is available otherwise return empty string
---@return string?
---@return string?
M.get_icon = function(filename)
if has_devicons then
local extension = vim.fn.fnamemodify(filename, ":e")
local icon, icon_hl = devicons.get_icon(filename, extension, { default = true })
if icon ~= nil then
return icon .. " ", icon_hl
else
return nil, nil
end
else
return nil, nil
end
end
return M