Files
gitlab.nvim/lua/gitlab/utils/init.lua
Harrison (Harry) Cramer b5b475ce8b 2.0.0 (#196)
This MR is a #MAJOR breaking change to the plugin. While the plugin will continue to work for users with their existing settings, they will be informed of outdated configuration (diagnostics and signs have been simplified) the next time they open the reviewer.

Fix: Trim trailing slash from custom URLs
Update: .github/CONTRIBUTING.md, .github/ISSUE_TEMPLATE/bug_report.md
Feat: Improve discussion tree toggling (#192)
Fix: Toggle modified notes (#188)
Fix: Toggle discussion nodes correctly
Feat: Show Help keymap in discussion tree winbar
Fix: Enable toggling nodes from the note body
Fix: Enable toggling resolved status from child nodes
Fix: Only try to show emoji popup on note nodes
Feat: Add keymap for toggling tree type
Fix: Disable tree type toggling in Notes
Fix Multi Line Issues (Large Refactor) (#197)
Fix: Multi-line discussions. The calculation of a range for a multiline comment has been consolidated and moved into the location.lua file. This does not attempt to fix diagnostics.
Refactor: It refactors the discussions code to split hunk parsing and management into a separate module
Fix: Don't allow comments on modified buffers #194 by preventing comments on the reviewer when using --imply-local and when the working tree is dirty entirely.
Refactor: It introduces a new List class for data aggregation, filtering, etc.
Fix: It removes redundant API calls and refreshes from the discussion pane
Fix: Location provider (#198)
Fix: add nil check for Diffview performance issue (#199)
Fix: Switch Tabs During Comment Creation (#200)
Fix: Check if file is modified (#201)
Fix: Off-By-One Issue in Old SHA (#202)
Fix: Rebuild Diagnostics + Signs (#203)
Fix: Off-By-One Issue in New SHA (#205)
Fix: Reviewer Jumps to wrong location (#206)

BREAKING CHANGE: Changes configuration of diagnostics and signs in the setup call.
2024-03-03 11:52:37 -05:00

627 lines
17 KiB
Lua

local List = require("gitlab.utils.list")
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
---Returns whether a string ends with a substring
---@param str string
---@param ending string
---@return boolean
M.ends_with = function(str, ending)
return ending == "" or str:sub(-#ending) == ending
end
M.filter = function(input_table, value_to_remove)
local resultTable = {}
for _, v in ipairs(input_table) do
if v ~= value_to_remove then
table.insert(resultTable, v)
end
end
return resultTable
end
M.filter_by_key_value = function(input_table, target_key, target_value)
local result_table = {}
for _, v in ipairs(input_table) do
if v[target_key] ~= target_value then
table.insert(result_table, v)
end
end
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
---Combines two list-like (non associative) tables, keeping values from both
---@param t1 table The first table
---@param ... table[] The first table
---@return table
M.combine = function(t1, ...)
local result = t1
local tables = { ... }
for _, t in ipairs(tables) do
for _, v in ipairs(t) do
table.insert(result, v)
end
end
return result
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
---Splits a string by new lines and returns an iterator
---@param s string The string to split
---@return table: An iterator object
M.split_by_new_lines = function(s)
if s:sub(-1) ~= "\n" then
s = s .. "\n"
end -- Append a new line to the string, if there's none, otherwise the last line would be lost.
return s:gmatch("(.-)\n") -- Match 0 or more (as few as possible) characters followed by a new line.
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.map = function(tbl, f)
local t = {}
for k, v in pairs(tbl) do
t[k] = f(v)
end
return t
end
M.reduce = function(tbl, agg, f)
for _, v in pairs(tbl) do
agg = f(agg, v)
end
return agg
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.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.difference = function(a, b)
local set_b = {}
for _, val in ipairs(b) do
set_b[val] = true
end
local not_included = {}
for _, val in ipairs(a) do
if not set_b[val] then
table.insert(not_included, val)
end
end
return not_included
end
---Get the popup view_opts
---@param title string The string to appear on top of the popup
---@param settings table User defined popup settings
---@param width number? Override default width
---@param height number? Override default height
---@return table
M.create_popup_state = function(title, settings, width, height, zindex)
local default_settings = require("gitlab.state").settings.popup
local user_settings = settings or {}
local view_opts = {
buf_options = {
filetype = "markdown",
},
relative = "editor",
enter = true,
focusable = true,
zindex = zindex or 50,
border = {
style = user_settings.border or default_settings.border,
text = {
top = title,
},
},
position = "50%",
size = {
width = user_settings.width or width or default_settings.width,
height = user_settings.height or height or default_settings.height,
},
opacity = user_settings.opacity or default_settings.opacity,
}
return view_opts
end
M.read_file = function(file_path, opts)
local file = io.open(file_path, "r")
if file == nil then
return nil
end
local file_contents = file:read("*all")
file:close()
if opts and opts.remove_newlines then
file_contents = string.gsub(file_contents, "\n", "")
end
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.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
-- Gets the window holding a buffer in the current tab page
---@param buffer_id number Id of a buffer
---@return integer|nil
M.get_window_id_by_buffer_id = function(buffer_id)
local tabpage = vim.api.nvim_get_current_tabpage()
local windows = vim.api.nvim_tabpage_list_wins(tabpage)
return List.new(windows):find(function(win_id)
local buf_id = vim.api.nvim_win_get_buf(win_id)
return buf_id == buffer_id
end)
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
files = List.new(folder)
:map(function(file)
local file_path = folder_path .. M.path_separator .. file
local timestamp = vim.fn.getftime(file_path)
return { name = file, timestamp = timestamp }
end)
:sort(function(a, b)
return a.timestamp > b.timestamp
end)
:map(function(file)
return file.name
end)
end
return files
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 content between start_line and end_line
---@param start_line integer
---@param end_line integer
---@return string[]
M.get_lines = function(start_line, end_line)
return vim.api.nvim_buf_get_lines(0, start_line - 1, end_line, false)
end
M.make_comma_separated_readable = function(str)
return string.gsub(str, ",", ", ")
end
---@param remote? boolean
M.get_all_git_branches = function(remote)
local branches = {}
local handle = remote == true and io.popen("git branch -r 2>&1") or io.popen("git branch 2>&1")
if handle then
for line in handle:lines() do
local branch
if remote then
for res in line:gmatch("origin/([^\n]+)") do
branch = res -- Trim /origin
end
else
branch = line:gsub("^%s*%*?%s*", "") -- Trim leading whitespace and the "* " marker for the current branch
end
table.insert(branches, branch)
end
handle:close()
else
M.notify("Error running 'git branch' command.", vim.log.levels.ERROR)
end
return branches
end
M.basename = function(str)
local name = string.gsub(str, "(.*/)(.*)", "%2")
return name
end
---@param url string?
M.open_in_browser = function(url)
if vim.fn.has("mac") == 1 then
vim.fn.jobstart({ "open", url })
elseif vim.fn.has("unix") == 1 then
vim.fn.jobstart({ "xdg-open", url })
else
M.notify("Opening a Gitlab URL is not supported on this OS!", vim.log.levels.ERROR)
end
end
---Trims the trailing slash from a URL
---@param s string
---@return string
M.trim_slash = function(s)
return (s:gsub("/+$", ""))
end
return M