vis-config

lua scripts to configure vis editor

git clone https://9o.is/git/vis-config.git

quickfix.lua

(13015B)


      1 -- SPDX-License-Identifier: GPL-3.0-or-later
      2 -- © 2020 Georgi Kirilov
      3 
      4 require("vis")
      5 local vis = vis
      6 
      7 local getcwd
      8 
      9 if vis:module_exist"lfs" then
     10 	require"lfs"
     11 	local lfs = lfs
     12 
     13 	getcwd = lfs.currentdir
     14 else
     15 	getcwd = function()
     16 		return io.popen"pwd":read"*l"
     17 	end
     18 end
     19 
     20 local progname = ...
     21 
     22 local M = {
     23 	grepformat  = {
     24 		"^%s*([^:]+):(%d+):(%d+):(.*)$", -- git-grep with --column
     25 		"^%s*([^:]+):(%d+):(.-)(.*)$",
     26 	},
     27 	errorformat = {
     28 		"^%s*([^:]+):(%d+):(%d+):(.*)$",
     29 		"^%s*([^:]+):(%d+):(.-)(.*)$",
     30 		"^(%S+) %S+ (%d+) (.-)(.*)$", -- cscope
     31 		[0] = {
     32 			["Entering directory [`']([^']+)'"] = true,
     33 			["Leaving directory [`']([^']+)'"] = false,
     34 		},
     35 	},
     36 	grepprg = "git grep --column",
     37 	makeprg = "make -k",
     38 	errorfile = "errors.err",
     39 	peek = true,
     40 	menu = false,
     41 	action = {},
     42 }
     43 
     44 local cwin
     45 local ctitle
     46 local lines = {valid = {}}
     47 local no_more = "No more items"
     48 local no_errors = "No Errors"
     49 
     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
     63 
     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
     70 
     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
     90 
     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
    103 		end
    104 		if cwin and w ~= cwin then
    105 			w:close()
    106 		end
    107 	end
    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
    112 		end
    113 	end
    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]
    119 		end
    120 	else
    121 		vis:command"new"
    122 	end
    123 	return true
    124 end
    125 
    126 local function counter(ccur)
    127 	return string.format("%s/%d",
    128 		ccur or "-",
    129 		#lines.valid)
    130 end
    131 
    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
    138 	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
    148 	end
    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}
    154 	end
    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
    159 		end
    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)
    166 	end
    167 	if not cwin and ln.message then
    168 		vis:info(string.format("[%s] %s", counter(ccur), ln.message:gsub("^%s", "")))
    169 	end
    170 	lines.ccur = ccur
    171 	lines.cline = cline
    172 end
    173 
    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
    180 	end
    181 	return table.unpack(location)
    182 end
    183 
    184 local function guard(func)
    185 	return function(...)
    186 		if #lines.valid == 0 then
    187 			vis:info(no_errors)
    188 			return
    189 		end
    190 		local ccur, cline = func(...)
    191 		if ccur and cline then
    192 			display(ccur, cline)
    193 		end
    194 	end
    195 end
    196 
    197 local cc = guard(function(count)
    198 	return _cc(count or lines.ccur or 1)
    199 end)
    200 
    201 local cnext = guard(function(count)
    202 	return _cc((lines.ccur or 0) + (count or 1))
    203 end)
    204 
    205 local cprev = guard(function(count)
    206 	return _cc((lines.ccur or 2) - (count or 1))
    207 end)
    208 
    209 local crewind = guard(function()
    210 	return _cc(1)
    211 end)
    212 
    213 local clast = guard(function()
    214 	return _cc(#lines.valid)
    215 end)
    216 
    217 local cnfile = guard(function(count)
    218 	count = count or 1
    219 	if not lines.ccur then
    220 		lines.ccur = 1
    221 	end
    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
    228 			end
    229 			if count == 0 then
    230 				return i, lines.valid[i][1]
    231 			end
    232 		end
    233 	end
    234 	vis:info(no_more)
    235 end)
    236 
    237 local cpfile = guard(function(count)
    238 	count = count or 1
    239 	if not lines.ccur then
    240 		lines.ccur = 1
    241 	end
    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
    248 			end
    249 			if count == 0 then
    250 				return i, lines.valid[i][1]
    251 			end
    252 		end
    253 	end
    254 	vis:info(no_more)
    255 end)
    256 
    257 local function open_error_window()
    258 	if cwin then return end
    259 	if not ctitle then
    260 		vis:info(no_errors)
    261 		return
    262 	end
    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
    274 	end
    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}
    280 	end
    281 	if #lines.valid > 0 then
    282 		if cwin.options then
    283 			cwin.options.cursorline = true
    284 		else
    285 			vis:command"set cursorline"
    286 		end
    287 	end
    288 	cwin:map(vis.modes.NORMAL, "<Enter>", function()
    289 		if #lines.valid == 0 then
    290 			vis:info(no_errors)
    291 			return
    292 		end
    293 		local location, err = find_nearest_after(vis.win.selection.line)
    294 		if not location then
    295 			vis:info(err)
    296 			return
    297 		end
    298 		display(table.unpack(location))
    299 		if M.menu then
    300 			cwin:close()
    301 		end
    302 	end)
    303 	botright_reopen(fname, lines.ccur)
    304 	vis:feedkeys"<vis-window-prev>"
    305 end
    306 
    307 local function cwindow()
    308 	if cwin then
    309 		cwin:close(true)
    310 	else
    311 		open_error_window()
    312 	end
    313 end
    314 
    315 local function store_from_string(str, fmt)
    316 	if str and string.len(str) == 0 then
    317 		str = nil
    318 	end
    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}
    325 	end
    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)
    338 				end
    339 			end
    340 		end
    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
    347 			end
    348 		end
    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)
    353 		end
    354 	end
    355 end
    356 
    357 local function store_from_file(errorfile)
    358 	if errorfile then
    359 		M.errorfile = errorfile
    360 	end
    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
    365 	end
    366 	local str = efile:read"*all"
    367 	efile:close()
    368 	store_from_string(str)
    369 	return true
    370 end
    371 
    372 local function store_from_window(win)
    373 	local str = win.file:content(0, win.file.size)
    374 	store_from_string(str)
    375 end
    376 
    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
    383 		end
    384 		ctitle = string.format(argv[1] and "%s %s" or "%s", argv[0], argv[1])
    385 		if was_open then
    386 			open_error_window()
    387 		end
    388 		crewind()
    389 	end
    390 end
    391 
    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()
    398 end
    399 
    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
    408 	end
    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
    414 	end
    415 	if was_open or M.peek or #lines.valid == 0 then
    416 		open_error_window()
    417 	end
    418 	if not M.peek and #lines.valid > 0 then
    419 		crewind()
    420 	end
    421 end
    422 
    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 .. "'"
    427 		end
    428 	end
    429 end
    430 
    431 local function cexpr(argv)
    432 	quote_spaces(argv)
    433 	_cexpr(table.concat(argv, " "))
    434 end
    435 
    436 local function grep(argv)
    437 	quote_spaces(argv)
    438 	table.insert(argv, 1, M.grepprg)
    439 	_cexpr(table.concat(argv, " "), M.grepformat)
    440 end
    441 
    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)
    446 end
    447 
    448 local function h(msg)
    449 	return string.format("|@%s| %s", progname, msg)
    450 end
    451 
    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"},
    462 	}
    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"},
    471 	}
    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)
    482 		end
    483 	end
    484 	for cmd, def in pairs(qcommands) do
    485 		local func, help = table.unpack(def)
    486 		vis:command_register(cmd, func, h(help))
    487 	end
    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
    494 		end
    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
    501 		end
    502 	end, h"Peek - :make, :grep, and :cex do not jump to the first error")
    503 end)
    504 
    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))
    511 		)
    512 end)
    513 
    514 vis.events.subscribe(vis.events.WIN_CLOSE, function(win)
    515 	if win ~= cwin then return end
    516 	cwin = nil
    517 end)
    518 
    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, lines.range.start, lines.range.finish)
    523 end)
    524 
    525 return M