Squash commits
[vis-quickfix.git] / init.lua
blobf1e1809b6e7a00af86f2391f083ee90a1da8b87e
1 -- SPDX-License-Identifier: GPL-3.0-or-later
2 -- © 2020 Georgi Kirilov
4 require("vis")
5 local vis = vis
7 local getcwd
9 if vis:module_exist"lfs" then
10 require"lfs"
11 local lfs = lfs
13 getcwd = lfs.currentdir
14 else
15 getcwd = function()
16 return io.popen"pwd":read"*l"
17 end
18 end
20 local progname = ...
22 local M = {
23 grepformat = {
24 "^%s*([^:]+):(%d+):(%d+):(.*)$", -- git-grep with --column
25 "^%s*([^:]+):(%d+):(.-)(.*)$",
27 errorformat = {
28 "^%s*([^:]+):(%d+):(%d+):(.*)$",
29 "^%s*([^:]+):(%d+):(.-)(.*)$",
30 "^(%S+) %S+ (%d+) (.-)(.*)$", -- cscope
31 [0] = {
32 ["Entering directory [`']([^']+)'"] = true,
33 ["Leaving directory [`']([^']+)'"] = false,
36 grepprg = "grep -n",
37 makeprg = "make -k",
38 errorfile = "errors.err",
39 peek = true,
40 menu = false,
41 action = {},
44 local cwin
45 local ctitle
46 local lines = {valid = {}}
47 local no_more = "No more items"
48 local no_errors = "No Errors"
50 local function find_nearest_after(line)
51 local ccur
52 for c, v in ipairs(lines.valid) do
53 if v[1] >= line then
54 ccur = c
55 break
56 end
57 end
58 if not ccur then
59 return nil, no_more
60 end
61 return {ccur, lines.valid[ccur][1]}
62 end
64 local function find_nth_valid(count)
65 if count < 1 or count > #lines.valid then
66 return nil, no_more
67 end
68 return {count, lines.valid[count][1]}
69 end
71 local function set_marks(win, ccur)
72 if not ccur then return end
73 local fname = win.file.name
74 local pwd = getcwd()
75 local pathname = fname:find("^/") and fname or pwd .. "/" .. fname
76 local i = ccur
77 while lines.valid[i] and lines.valid[i].path == pathname do
78 i = i - 1
79 end
80 local ln = lines.valid[i + 1]
81 while ln and ln.path == pathname do
82 -- I wish there was a way to convert from ln:col to pos
83 -- without setting the selection
84 win.selection:to(ln.line, ln.column or 1)
85 ln.mark = win.file:mark_set(win.selection.pos)
86 i = i + 1
87 ln = lines.valid[i]
88 end
89 end
91 local function botright_reopen(filename, ccur)
92 -- This function closes and opens windows in a specific order, just so
93 -- the error window ends up at bottom position.
94 -- This is fragile, and does not work in all possible situations.
95 -- Having a window with a modified file is one example.
96 -- Even if the file was not modified, closing the window will lead to loss of
97 -- any state local to it.
98 -- It would be nice if vis had :botright or something to that effect.
99 local cursors = {}
100 for w in vis:windows() do
101 if w.file.name then
102 cursors[w.file.name] = w.selection.pos
104 if cwin and w ~= cwin then
105 w:close()
108 for w in vis:windows() do
109 if w.file.name and w.file.modified then
110 vis:info"No write since last change"
111 return false
114 if filename then
115 vis:command(string.format((cwin and "open" or "e") .. " %q", filename))
116 set_marks(vis.win, ccur)
117 if cursors[filename] then
118 vis.win.selection.pos = cursors[filename]
120 else
121 vis:command"new"
123 return true
126 local function counter(ccur)
127 return string.format("%s/%d",
128 ccur or "-",
129 #lines.valid)
132 local function display(ccur, cline)
133 local ln = lines.valid[ccur]
134 local pwd = getcwd()
135 local cname = ln.path:find(pwd) and ln.path:gsub(pwd, "", 1):gsub("^/", "") or ln.path
136 if vis.win.file.name ~= cname then
137 if not botright_reopen(ln.path, ccur) then return end
139 local column = ln.column
140 if type(column) ~= "number" then
141 column = nil
142 -- else
143 -- TODO: some tools report virtual columns, others - physical.
144 -- local indent = vis.win.file.lines[ln.line]:match"^%s+" or ""
145 -- local _, tabs = indent:gsub("\t", "")
146 -- -- XXX: assume tools to report 8 columns-wide tabs
147 -- column = column - tabs * 8 + tabs
149 if cwin then
150 cwin.selection:to(cline, 1)
151 local pos = cwin.selection.pos
152 local clen = cwin.file.lines[cline]
153 lines.range = {start = pos, finish = pos + #clen - 1}
155 if ln.mark then
156 local newpos = vis.win.file:mark_get(ln.mark)
157 if newpos then
158 vis.win.selection.pos = newpos
160 else
161 -- XXX: degrade to using raw line:column;
162 -- so far, only triggered by consecutive hard links
163 -- where vis keeps the old file.name but the error list
164 -- switches to the new. set_marks gets confused and sets no marks.
165 vis.win.selection:to(ln.line, column or 1)
167 if not cwin and ln.message then
168 vis:info(string.format("[%s] %s", counter(ccur), ln.message:gsub("^%s", "")))
170 lines.ccur = ccur
171 lines.cline = cline
174 local function _cc(count)
175 if not count then return end
176 local location, err = find_nth_valid(count)
177 if not location then
178 vis:info(err)
179 return
181 return table.unpack(location)
184 local function guard(func)
185 return function(...)
186 if #lines.valid == 0 then
187 vis:info(no_errors)
188 return
190 local ccur, cline = func(...)
191 if ccur and cline then
192 display(ccur, cline)
197 local cc = guard(function(count)
198 return _cc(count or lines.ccur or 1)
199 end)
201 local cnext = guard(function(count)
202 return _cc((lines.ccur or 0) + (count or 1))
203 end)
205 local cprev = guard(function(count)
206 return _cc((lines.ccur or 2) - (count or 1))
207 end)
209 local crewind = guard(function()
210 return _cc(1)
211 end)
213 local clast = guard(function()
214 return _cc(#lines.valid)
215 end)
217 local cnfile = guard(function(count)
218 count = count or 1
219 if not lines.ccur then
220 lines.ccur = 1
222 local cur_fname = lines.valid[lines.ccur].path
223 for i = lines.ccur + 1, #lines.valid do
224 local filename = lines.valid[i].path
225 if filename then
226 if filename ~= cur_fname then
227 count = count - 1
229 if count == 0 then
230 return i, lines.valid[i][1]
234 vis:info(no_more)
235 end)
237 local cpfile = guard(function(count)
238 count = count or 1
239 if not lines.ccur then
240 lines.ccur = 1
242 local cur_fname = lines.valid[lines.ccur].path
243 for i = lines.ccur - 1, 1, -1 do
244 local filename = lines.valid[i].path
245 if filename then
246 if filename ~= cur_fname then
247 count = count - 1
249 if count == 0 then
250 return i, lines.valid[i][1]
254 vis:info(no_more)
255 end)
257 local function open_error_window()
258 if cwin then return end
259 if not ctitle then
260 vis:info(no_errors)
261 return
263 local fname = vis.win.file.name
264 vis:command"new"
265 cwin = vis.win
266 cwin.file:insert(0, lines.buffer or "")
267 cwin.file.modified = false
268 local cline1
269 if lines.cline then
270 cline1 = lines.cline
271 else
272 local first = find_nth_valid(1)
273 cline1 = first and first[2] or 1
275 cwin.selection:to(cline1, 1)
276 if lines.cline then
277 local pos = cwin.selection.pos
278 local clen = cwin.file.lines[lines.cline]
279 lines.range = {start = pos, finish = pos + #clen - 1}
281 if #lines.valid > 0 then
282 if cwin.options then
283 cwin.options.cursorline = true
284 else
285 vis:command"set cursorline"
288 cwin:map(vis.modes.NORMAL, "<Enter>", function()
289 if #lines.valid == 0 then
290 vis:info(no_errors)
291 return
293 local location, err = find_nearest_after(vis.win.selection.line)
294 if not location then
295 vis:info(err)
296 return
298 display(table.unpack(location))
299 if M.menu then
300 cwin:close()
302 end)
303 botright_reopen(fname, lines.ccur)
304 vis:feedkeys"<vis-window-prev>"
307 local function cwindow()
308 if cwin then
309 cwin:close(true)
310 else
311 open_error_window()
315 local function store_from_string(str, fmt)
316 if str and string.len(str) == 0 then
317 str = nil
319 lines = {buffer = str, valid = {}}
320 if not lines.buffer then return end
321 if not fmt then
322 fmt = M.errorformat
323 elseif type(fmt) == "string" then
324 fmt = {fmt}
326 local i = 0
327 local dirstack = {}
328 local pwd = getcwd()
329 for ln in lines.buffer:gmatch("[^\n]*") do
330 i = i + 1
331 for patt, push in pairs(fmt[0] or {}) do
332 local dir = ln:match(patt)
333 if dir then
334 if push then
335 table.insert(dirstack, dir)
336 elseif dirstack[#dirstack] == dir then
337 table.remove(dirstack)
341 local cwd = dirstack[#dirstack] or pwd
342 local filename, line, column, message
343 for _, f in ipairs(fmt) do
344 filename, line, column, message = ln:match(f)
345 if filename and line then
346 break
349 if filename and line then
350 local pathname = filename:find("^/") and filename or string.format("%s/%s", cwd, filename)
351 local t = {i, path = pathname, line = tonumber(line), column = tonumber(column), message = message}
352 table.insert(lines.valid, t)
357 local function store_from_file(errorfile)
358 if errorfile then
359 M.errorfile = errorfile
361 local efile = io.open(errorfile or M.errorfile)
362 if not efile then
363 vis:info(string.format("Can't open errorfile %s", errorfile or M.errorfile))
364 return
366 local str = efile:read"*all"
367 efile:close()
368 store_from_string(str)
369 return true
372 local function store_from_window(win)
373 local str = win.file:content(0, win.file.size)
374 store_from_string(str)
377 local function cfile(argv)
378 if store_from_file(argv[1]) then
379 local was_open
380 if cwin then
381 cwin:close(true)
382 was_open = true
384 ctitle = string.format(argv[1] and "%s %s" or "%s", argv[0], argv[1])
385 if was_open then
386 open_error_window()
388 crewind()
392 local function cbuffer(argv)
393 store_from_window(vis.win)
394 local fname = vis.win.file.name
395 ctitle = string.format(fname and "%s (%s)" or "%s", argv[0], fname)
396 vis.win.file.modified = false
397 crewind()
400 local function _cexpr(cmd, fmt, title, is_make)
401 if not cmd or #cmd == 0 then vis:info"Argument required" return end
402 ctitle = title or cmd
403 local code, stdout = vis:pipe(nil, nil, cmd .. " 2>&1")
404 local was_open
405 if cwin then
406 cwin:close(true)
407 was_open = true
409 store_from_string(stdout, fmt)
410 lines.code = code ~= 0 and code or nil
411 if is_make and code == 0 then
412 vis:info(string.format("'%s' finished", M.makeprg))
413 return
415 if was_open or M.peek or #lines.valid == 0 then
416 open_error_window()
418 if not M.peek and #lines.valid > 0 then
419 crewind()
423 local function quote_spaces(argv)
424 for i, arg in ipairs(argv) do
425 if arg:find("[ \t\n]") then
426 argv[i] = "'" .. arg .. "'"
431 local function cexpr(argv)
432 quote_spaces(argv)
433 _cexpr(table.concat(argv, " "))
436 local function grep(argv)
437 quote_spaces(argv)
438 table.insert(argv, 1, M.grepprg)
439 _cexpr(table.concat(argv, " "), M.grepformat)
442 local function make(argv)
443 quote_spaces(argv)
444 table.insert(argv, 1, M.makeprg)
445 _cexpr(table.concat(argv, " "), M.errorformat, nil, true)
448 local function h(msg)
449 return string.format("|@%s| %s", progname, msg)
452 vis.events.subscribe(vis.events.INIT, function()
453 -- These commands assume an existing error list:
454 local ccommands = {
455 cn = {cnext, "Display the [arg]-th next error"},
456 cp = {cprev, "Display the [arg]-th previous error"},
457 cnf = {cnfile, "Display the first error in the [arg]-th next file"},
458 cpf = {cpfile, "Display the last error in the [arg]-th previous file"},
459 cc = {cc, "Display [arg]-th error. If [arg] is omitted, the same error is displayed again."},
460 cr = {crewind, "Display the first error"},
461 cla = {clast, "Display the last error"},
463 -- These commands create a new error list:
464 local qcommands = {
465 cf = {cfile, "Read the error list from [arg]"},
466 cb = {cbuffer, "Read the error list from the current file"},
467 cex = {cexpr, "Create an error list using the result of [args]"},
468 grep = {grep, string.format("Create an error list using the result of '%s'", M.grepprg)},
469 make = {make, string.format("Create an error list using the result of '%s'", M.makeprg)},
470 cw = {cwindow, "Toggle the error window"},
472 for cmd, def in pairs(ccommands) do
473 local func, help = table.unpack(def)
474 vis:command_register(cmd, function(argv)
475 local count = argv[1] and tonumber(argv[1])
476 func(count)
477 end, h(help))
478 M.action[cmd] = function(arg)
479 -- XXX: do not convert, say, "1" to 1; a digit can be passed by vis.map but it is not a count
480 local count = type(arg) == "number" and arg
481 func(count)
484 for cmd, def in pairs(qcommands) do
485 local func, help = table.unpack(def)
486 vis:command_register(cmd, func, h(help))
488 M.cexpr = _cexpr
489 vis:option_register("qfm", "bool", function(value, toggle)
490 if toggle then
491 M.menu = not M.menu
492 else
493 M.menu = value
495 end, h"Menu - jumping to an error with <Enter> closes the error window")
496 vis:option_register("qfp", "bool", function(value, toggle)
497 if toggle then
498 M.peek = not M.peek
499 else
500 M.peek = value
502 end, h"Peek - :make, :grep, and :cex do not jump to the first error")
503 end)
505 vis.events.subscribe(vis.events.WIN_STATUS, function(win)
506 if win ~= cwin then return end
507 win:status(
508 string.format(" [Quickfix List]%s :%s", (win.file.modified and " [+]" or ""), ctitle),
509 lines.code and string.format("exit: %d « [%s] ", lines.code, counter(lines.ccur))
510 or string.format("[%s] ", counter(lines.ccur))
512 end)
514 vis.events.subscribe(vis.events.WIN_CLOSE, function(win)
515 if win ~= cwin then return end
516 cwin = nil
517 end)
519 vis.events.subscribe(vis.events.WIN_HIGHLIGHT, function(win)
520 if win ~= cwin then return end
521 if not (lines and lines.range) then return end
522 win:style(win.STYLE_CURSOR_PRIMARY, lines.range.start, lines.range.finish)
523 end)
525 return M