vis-config
lua scripts to configure vis editor
git clone https://9o.is/git/vis-config.git
util.lua
(8586B)
1 --- Module containing simple utility functions for vis-lspc/
2 -- @module util
3 -- @author Florian Fischer
4 -- @author git-bruh <prathamIN@proton.me>
5 -- @license GPL-3
6 -- @copyright 2024 Florian Fischer
7 -- @copyright 2024 git-bruh <prathamIN@proton.me>
8 local util = {}
9
10 local source_str = debug.getinfo(1, 'S').source:sub(2)
11 local source_path = source_str:match('(.*/)')
12
13 local lspc
14
15 function util.init(lspc_)
16 lspc = lspc_
17 return util
18 end
19
20 --- Execute a command and capture its output.
21 -- @param cmd the command to execute
22 -- @return the output of the command written to stdout
23 function util.capture_cmd(cmd)
24 local p = assert(io.popen(cmd, 'r'))
25 local s = assert(p:read('*a'))
26 local success, _, status = p:close()
27 if not success then
28 local err = cmd .. ' failed with exit code: ' .. status
29 lspc:err(err)
30 end
31 return s
32 end
33
34 local vis_supports_pipe_buf = pcall(vis.pipe, vis, 'foo', 'true', false)
35
36 --- Wrapper for the two vis:pipe variants.
37 -- If vis does not support vis:pipe(input, cmd), prefix the command
38 -- with a printf call piping the result to the original command.
39 -- @param input The input to pipe to the command
40 -- @param cmd The external command to pipe the input to
41 function util.vis_pipe(input, cmd, fullscreen)
42 if vis_supports_pipe_buf then
43 return vis:pipe(input, cmd, fullscreen or false)
44 end
45
46 local escaped_input = input:gsub('\'', '\'"\'"\'')
47 cmd = 'printf %s \'' .. escaped_input .. '\' | ' .. cmd
48 return vis:pipe(vis.win.file, {start = 0, finish = 0}, cmd)
49 end
50
51 --- Split a path into its components
52 -- @param path the path to split into components
53 -- @return a table containing the path components
54 function util.split_path_into_components(path)
55 local components = {}
56
57 if #path == 1 then
58 return nil
59 end
60
61 -- Skip the initial '/'
62 local start_idx = 2
63
64 while true do
65 local slash = path:find('/', start_idx + 1)
66
67 if slash == nil then
68 table.insert(components, path:sub(start_idx, #path))
69 return components
70 else
71 table.insert(components, path:sub(start_idx, slash - 1))
72 start_idx = slash + 1
73 end
74 end
75 end
76
77 --- Get a path relative to the current working directory
78 -- @param cwd_components Table of the path components of the CWD
79 -- @param absolute_path_or_components absolute path or table of its path components
80 -- @return the relative path
81 function util.get_relative_path(cwd_components, absolute_path_or_components)
82 local absolute_components
83 if type(absolute_path_or_components) == 'string' then
84 absolute_components = util.split_path_into_components(absolute_path_or_components)
85 else
86 absolute_components = absolute_path_or_components
87 end
88
89 for idx = 1, #cwd_components do
90 local cwd = cwd_components[idx]
91 local absolute = absolute_components[idx]
92
93 if cwd ~= absolute then
94 local dir = ''
95
96 -- Atleast the first component must match for us to convert
97 -- it to a relative path
98 if idx ~= 1 then
99 for _ = idx, #cwd_components do
100 dir = dir .. '..' .. '/'
101 end
102
103 -- Skip trailing '/'
104 dir = dir:sub(1, #dir - 1)
105 end
106
107 for i = idx, #absolute_components do
108 dir = dir .. '/' .. absolute_components[i]
109 end
110
111 return dir
112 end
113 end
114
115 -- cwd shorter than absolute path
116 local dir = ''
117
118 for i = #cwd_components + 1, #absolute_components do
119 dir = dir .. '/' .. absolute_components[i]
120 end
121
122 -- Skip leading '/'
123 return dir:sub(2)
124 end
125
126 --- Strip the last component from a pathname
127 -- @param the pathname
128 -- @return the pathname up to the last '/'
129 function util.dirname(name)
130 if name == '.' or name == '..' or name == '/' then
131 return name
132 end
133
134 -- strip a trailing path separator
135 if name:sub(#name, #name) == '/' then
136 name = name:sub(1, #name - 1)
137 end
138
139 local dirname = name:match('(.*)[/]')
140 -- There was no path separator in name.
141 if not dirname then
142 return '.'
143 end
144
145 -- The name started with the root dir.
146 if dirname == '' then
147 return '/'
148 end
149
150 return dirname
151 end
152
153 --- Create an iterator yielding the nth line of a file
154 --
155 -- @param path The path to the file
156 function util.file_line_iterator_to_n(path)
157 local file = assert(io.open(path, 'r'))
158 local lines = file:lines()
159 local last_line = nil
160 local last_n = 1
161
162 return function(n)
163 if n == -1 then
164 file:close()
165 return nil
166 end
167
168 if n < last_n then
169 -- We might have multiple references on the same line, so we can
170 -- get called again with the previous line number
171 if (n + 1) == last_n then
172 return last_line
173 end
174
175 return nil
176 end
177
178 for line in lines do
179 if n == last_n then
180 last_n = last_n + 1
181 last_line = line
182
183 return line
184 end
185
186 last_n = last_n + 1
187 end
188
189 -- Iterator exhausted
190 return nil
191 end
192 end
193
194 --- Find file based on globs in the parent file system tree
195 -- @param globs a new line separated string of file globs
196 -- @param start the starting path
197 function util.find_upwards(globs, start)
198 local status, out = util.vis_pipe(globs, '\'' .. source_path:gsub('\'', '\'\\\'\'') ..
199 '/tools/find-upwards\' "' .. start .. '"')
200
201 if status ~= 0 or out == nil then
202 return nil
203 end
204
205 -- Skip trailing newline
206 return out:sub(1, #out - 1)
207 end
208
209 -- get the vis_selection from current primary selection
210 local function get_selection(win)
211 return {line = win.selection.line, col = win.selection.col}
212 end
213
214 --- Calculate the 0-based byte offsets from multiple sorted selections
215 -- @param file the file to calculate the positions in
216 -- @param sorted_selections a table of sorted vis_selections
217 -- @return a table of positions
218 function util.vis_sorted_selections_to_pos(file, sorted_selections)
219 local positions = {}
220 if file.pos_by_linecol then
221 for _, sel in ipairs(sorted_selections) do
222 table.insert(positions, file:pos_by_linecol(sel.line, sel.col))
223 end
224 return positions
225 end
226
227 local line_count = 0
228 local pos = 0
229 local sel_i = 1
230 local sel = sorted_selections[sel_i]
231 for line in file:lines_iterator() do
232 line_count = line_count + 1
233 while line_count == sel.line do
234 table.insert(positions, pos + (sel.col - 1))
235 sel_i = sel_i + 1
236 -- no more selections to convert
237 if sel_i > #sorted_selections then
238 break
239 end
240 sel = sorted_selections[sel_i]
241 end
242
243 pos = pos + #line + 1
244 end
245 return positions
246 end
247
248 local function vis_pos_before(p1, p2)
249 return p1.line < p2.line or (p1.line == p2.line and p1.col < p2.col)
250 end
251
252 --- Calculate the 0-based byte offsets from multiple selections
253 -- @param file the file to calculate the positions in
254 -- @param selections a table of selections
255 -- @return a table of positions
256 function util.vis_selections_to_pos(file, selections)
257 table.sort(selections, vis_pos_before)
258 return util.vis_sorted_selections_to_pos(file, selections)
259 end
260
261 --- Get the line and column from a 0-based byte offset
262 -- ATTENTION: the fallback version of this function modifies the primary
263 -- selection so it is not safe to call it for example during WIN_HIGHLIGHT events
264 -- @param pos the 0-based byte offset into the file
265 -- @return the 1-based line number
266 -- @return the 1-based column
267 function util.vis_pos_to_sel(win, pos)
268 if win.file.linecol_by_pos then
269 local lineno, col = win.file:linecol_by_pos(pos)
270 return {line = lineno, col = col}
271 end
272
273 local old_selection = get_selection(win)
274 -- move primary selection
275 win.selection.pos = pos
276 local sel = get_selection(win)
277 -- restore old primary selection
278 win.selection:to(old_selection.line, old_selection.col)
279 return sel
280 end
281
282 --- Count the visual characters in a line
283 -- This is useful to detect wrapped lines including tabs which may add a
284 -- unspecified amount of white space to a line depending on their position and
285 -- the used tabwidth.
286 -- @param win the window containing the line
287 -- @param line the line to count
288 -- @param nchars the number of characters in the line
289 -- @return the number of visual characters
290 util.visual_chars_in_line = function(win, line, nchars)
291 -- fast string iteration inspired by:
292 -- https://stackoverflow.com/a/49222705
293 local l = {string.byte(line, 1, nchars)}
294 local line_len = 0
295 for i = 1, nchars do
296 local c = l[i] -- Note: produces char codes instead of chars.
297 if c == 9 then -- '\t'
298 local chars_to_tab_stop = win.options.tabwidth - (line_len % win.options.tabwidth)
299 line_len = line_len + chars_to_tab_stop
300 else
301 line_len = line_len + 1
302 end
303 end
304 return line_len
305 end
306
307 return util