-
Notifications
You must be signed in to change notification settings - Fork 50
Expand file tree
/
Copy pathutil.lua
More file actions
413 lines (352 loc) · 10.3 KB
/
util.lua
File metadata and controls
413 lines (352 loc) · 10.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
local Path = require('plenary.path')
local M = {}
function M.uid()
return tostring(os.time()) .. '-' .. tostring(math.random(1000, 9999))
end
function M.is_buf_a_file(bufnr)
bufnr = bufnr or vim.api.nvim_get_current_buf()
if not vim.api.nvim_buf_is_valid(bufnr) then
return false
end
local buftype = vim.bo[bufnr].buftype
local filepath = vim.api.nvim_buf_get_name(bufnr)
-- Valid files have empty buftype
-- This excludes special buffers like help, terminal, nofile, etc.
return buftype == '' and filepath ~= ''
end
function M.indent_code_block(text)
if not text then
return nil
end
local lines = vim.split(text, '\n', { plain = true })
local first, last = nil, nil
for i, line in ipairs(lines) do
if line:match('[^%s]') then
first = first or i
last = i
end
end
if not first then
return ''
end
local content = {}
for i = first, last do
table.insert(content, lines[i])
end
local min_indent = math.huge
for _, line in ipairs(content) do
if line:match('[^%s]') then
min_indent = math.min(min_indent, line:match('^%s*'):len())
end
end
if min_indent < math.huge and min_indent > 0 then
for i, line in ipairs(content) do
if line:match('[^%s]') then
content[i] = line:sub(min_indent + 1)
end
end
end
return vim.trim(table.concat(content, '\n'))
end
-- Reset all ANSI styling
function M.ansi_reset()
return '\27[0m'
end
---Remove ANSI escape sequences
---@param str string: Input string containing ANSI escape codes
---@return string stripped_str
function M.strip_ansi(str)
return (str:gsub('\27%[[%d;]*m', ''))
end
---Strip ANSI escape sequences from all lines
---@param lines table
---@return table stripped_lines
function M.sanitize_lines(lines)
local stripped_lines = {}
for _, line in pairs(lines) do
table.insert(stripped_lines, M.strip_ansi(line))
end
return stripped_lines
end
--- Format a timestamp as time (e.g., "10:23 AM", "13 Oct 03:32 PM" "13 Oct 2025 03:32 PM")
--- @param timestamp number
--- @return string: Formatted time string
function M.format_time(timestamp)
local formats = { day = '%I:%M %p', year = '%d %b %I:%M %p', full = '%d %b %Y %I:%M %p' }
if timestamp > 1e12 then
timestamp = math.floor(timestamp / 1000)
end
local same_day = os.date('%Y-%m-%d') == os.date('%Y-%m-%d', timestamp)
local same_year = os.date('%Y') == os.date('%Y', timestamp)
local format_str = same_day and formats.day or (same_year and formats.year or formats.full)
return os.date(format_str, timestamp) --[[@as string]]
end
function M.index_of(tbl, value)
for i, v in ipairs(tbl) do
if v == value then
return i
end
end
return nil
end
function M.find_index_of(tbl, predicate)
for i, v in ipairs(tbl) do
if predicate(v) then
return i
end
end
return nil
end
function M.some(tbl, predicate)
for _, v in ipairs(tbl) do
if predicate(v) then
return true
end
end
return false
end
local _is_git_project = nil
function M.is_git_project()
if _is_git_project ~= nil then
return _is_git_project
end
local git_dir = Path:new(vim.fn.getcwd()):joinpath('.git')
_is_git_project = git_dir:exists()
return _is_git_project
end
function M.format_number(n)
if not n or n <= 0 then
return nil
end
if n >= 1e6 then
return string.format('%.1fM', n / 1e6)
elseif n >= 1e3 then
return string.format('%.1fK', n / 1e3)
else
return tostring(n)
end
end
function M.format_percentage(n)
return n and n > 0 and string.format('%.1f%%', n * 100) or nil
end
function M.format_cost(c)
return c and c > 0 and string.format('$%.2f', c) or nil
end
function M.debounce(func, delay)
local timer = nil
return function(...)
if timer then
timer:stop()
end
local args = { ... }
timer = vim.defer_fn(function()
func(unpack(args))
end, delay or 100)
end
end
---@param dir string Directory path to read JSON files from
---@param max_items? number Maximum number of items to read
---@return table[]|nil Array of decoded JSON objects
function M.read_json_dir(dir, max_items)
if not dir or vim.fn.isdirectory(dir) == 0 then
return nil
end
local count = 0
local decoded_items = {}
for file, file_type in vim.fs.dir(dir) do
if file_type == 'file' and file:match('%.json$') then
local file_ok, content = pcall(vim.fn.readfile, dir .. '/' .. file)
if file_ok then
local lines = table.concat(content, '\n')
local ok, data = pcall(vim.json.decode, lines)
if ok and data then
table.insert(decoded_items, data)
end
end
end
count = count + 1
if max_items and count >= max_items then
break
end
end
if #decoded_items == 0 then
return nil
end
return decoded_items
end
--- Safely call a function if it exists.
--- @param fn function|nil
--- @param ... any
function M.safe_call(fn, ...)
local arg = { ... }
return fn and vim.schedule(function()
fn(unpack(arg))
end)
end
---@param version string
---@return number|nil, number|nil, number|nil
function M.parse_semver(version)
if not version or version == '' then
return nil
end
local major, minor, patch = version:match('(%d+)%.(%d+)%.?(%d*)')
if not major then
return nil
end
return tonumber(major) or 0, tonumber(minor) or 0, tonumber(patch) or 0
end
---@param version string
---@param required_version string
---@return boolean
function M.is_version_greater_or_equal(version, required_version)
local major, minor, patch = M.parse_semver(version)
local req_major, req_minor, req_patch = M.parse_semver(required_version)
if not major or not req_major then
return false
end
if major ~= req_major then
return major > req_major
end
if minor ~= req_minor then
return minor > req_minor
end
return patch >= req_patch
end
--- Parse arguments in the form of key=value, supporting dot notation for nested tables.
--- Example: "context.selection.enabled=false options
--- @param args_str string
--- @return table
function M.parse_dot_args(args_str)
local result = {}
for arg in string.gmatch(args_str, '[^%s]+') do
local key, value = arg:match('([^=]+)=([^=]+)')
if key and value then
local parts = vim.split(key, '.', { plain = true })
local t = result
for i = 1, #parts - 1 do
t[parts[i]] = t[parts[i]] or {}
t = t[parts[i]]
end
if value == 'true' then
value = true
elseif value == 'false' then
value = false
elseif tonumber(value) then
value = tonumber(value)
end
t[parts[#parts]] = value
end
end
return result
end
--- Check if prompt is allowed via guard callback
--- @param guard_callback? function
--- @param mentioned_files? string[] List of mentioned files in the context
--- @return boolean allowed
--- @return string|nil error_message
function M.check_prompt_allowed(guard_callback, mentioned_files)
if not guard_callback then
return true, nil -- No guard = always allowed
end
if not type(guard_callback) == 'function' then
return false, 'prompt_guard must be a function'
end
mentioned_files = mentioned_files or {}
local success, result = pcall(guard_callback, mentioned_files)
if not success then
return false, 'prompt_guard error: ' .. tostring(result)
end
if type(result) ~= 'boolean' then
return false, 'prompt_guard must return a boolean'
end
---@cast result boolean
return result, nil
end
--- Get the markdown type to use based on the filename. First gets the neovim type
--- for the file. Then apply any specific overrides. Falls back to using the file
--- extension if nothing else matches
--- @param filename string filename, possibly including path
--- @return string markdown_filetype
function M.get_markdown_filetype(filename)
if not filename or filename == '' then
return ''
end
local file_type_overrides = {
javascriptreact = 'jsx',
typescriptreact = 'tsx',
sh = 'bash',
yaml = 'yml',
text = 'txt', -- nvim 0.12-nightly returns text as the type which breaks our unit tests
}
local file_type = vim.filetype.match({ filename = filename }) or ''
if file_type_overrides[file_type] then
return file_type_overrides[file_type]
end
if file_type and file_type ~= '' then
return file_type
end
return vim.fn.fnamemodify(filename, ':e')
end
function M.strdisplaywidth(str)
local str = str:gsub('%%#.-#', ''):gsub('%%[%*]', '')
return vim.fn.strdisplaywidth(str)
end
--- Parse run command arguments with optional agent, model, and context prefixes.
--- Returns opts table and remaining prompt string.
--- Format: [agent=<name>] [model=<model>] [context=<key=value,...>] <prompt>
--- @param args string[]
--- @return table opts, string prompt
function M.parse_run_args(args)
local opts = {}
local prompt_start_idx = 1
for i, token in ipairs(args) do
local agent = token:match('^agent=(.+)$')
local model = token:match('^model=(.+)$')
local context = token:match('^context=(.+)$')
if agent then
opts.agent = agent
prompt_start_idx = i + 1
elseif model then
opts.model = model
prompt_start_idx = i + 1
elseif context then
opts.context = M.parse_dot_args(context:gsub(',', ' '))
prompt_start_idx = i + 1
else
break
end
end
local prompt_tokens = vim.list_slice(args, prompt_start_idx)
local prompt = table.concat(prompt_tokens, ' ')
return opts, prompt
end
---pcall but returns a full stacktrace on error
function M.pcall_trace(fn, ...)
return xpcall(fn, function(err)
return debug.traceback(err, 2)
end, ...)
end
function M.is_path_in_cwd(path)
local cwd = vim.fn.getcwd()
local abs_path = vim.fn.fnamemodify(path, ':p')
return abs_path:sub(1, #cwd) == cwd
end
--- Check if a given path is in the system temporary directory.
--- Optionally match the filename against a pattern.
--- @param path string File path to check
--- @param pattern string|nil Optional Lua pattern to match the filename
--- @return boolean is_temp
function M.is_temp_path(path, pattern)
local temp_dir = vim.fn.tempname()
temp_dir = vim.fn.fnamemodify(temp_dir, ':h')
local abs_path = vim.fn.fnamemodify(path, ':p')
if abs_path:sub(1, #temp_dir) ~= temp_dir then
return false
end
if pattern then
local filename = vim.fn.fnamemodify(path, ':t')
return filename:match(pattern) ~= nil
end
return true
end
return M