dotfiles/.config/lite-xl/plugins/lsp_snippets.lua

793 lines
21 KiB
Lua

-- mod-version:3
-- LSP style snippet parser
-- shamelessly 'inspired by' (stolen from) LuaSnip
-- https://github.com/L3MON4D3/LuaSnip/blob/master/lua/luasnip/util/parser/neovim_parser.lua
local core = require 'core'
local common = require 'core.common'
local Doc = require 'core.doc'
local system = require 'system'
local regex = require 'regex'
local snippets = require 'plugins.snippets'
local json do
local ok, j
for _, p in ipairs {
'plugins.json', 'plugins.lsp.json', 'plugins.lintplus.json',
'libraries.json'
} do
ok, j = pcall(require, p)
if ok then json = j; break end
end
end
local B = snippets.builder
local LAST_CONVERTED_ID = { }
local THREAD_KEY = { }
-- node factories
local function doc_syntax(doc, k)
return doc.syntax and doc.syntax[k]
end
local variables = {
-- LSP
TM_SELECTED_TEXT = function(ctx) return ctx.selection end,
TM_CURRENT_LINE = function(ctx) return ctx.doc.lines[ctx.line] end,
TM_CURRENT_WORD = function(ctx) return ctx.partial end,
TM_LINE_INDEX = function(ctx) return ctx.line - 1 end,
TM_LINE_NUMBER = function(ctx) return ctx.line end,
TM_FILENAME = function(ctx) return ctx.doc.filename:match('[^/%\\]*$') or '' end,
TM_FILENAME_BASE = function(ctx) return ctx.doc.filename:match('([^/%\\]*)%.%w*$') or ctx.doc.filename end,
TM_DIRECTORY = function(ctx) return ctx.doc.filename:match('([^/%\\]*)[/%\\].*$') or '' end,
TM_FILEPATH = function(ctx) return common.dirname(ctx.doc.abs_filename) or '' end,
-- VSCode
RELATIVE_FILEPATH = function(ctx) return core.normalize_to_project_dir(ctx.doc.filename) end,
CLIPBOARD = function() return system.get_clipboard() end,
-- https://github.com/lite-xl/lite-xl/pull/1455
WORKSPACE_NAME = function(ctx) return end,
WORKSPACE_FOLDER = function(ctx) return end,
CURSOR_INDEX = function(ctx) return ctx.col - 1 end,
CURSOR_NUMBER = function(ctx) return ctx.col end,
CURRENT_YEAR = function() return os.date('%G') end,
CURRENT_YEAR_SHORT = function() return os.date('%g') end,
CURRENT_MONTH = function() return os.date('%m') end,
CURRENT_MONTH_NAME = function() return os.date('%B') end,
CURRENT_MONTH_NAME_SHORT = function() return os.date('%b') end,
CURRENT_DATE = function() return os.date('%d') end,
CURRENT_DAY_NAME = function() return os.date('%A') end,
CURRENT_DAY_NAME_SHORT = function() return os.date('%a') end,
CURRENT_HOUR = function() return os.date('%H') end,
CURRENT_MINUTE = function() return os.date('%M') end,
CURRENT_SECOND = function() return os.date('%S') end,
CURRENT_SECONDS_UNIX = function() return os.time() end,
RANDOM = function() return string.format('%06d', math.random(999999)) end,
RANDOM_HEX = function() return string.format('%06x', math.random(0xFFFFFF)) end,
BLOCK_COMMENT_START = function(ctx) return (doc_syntax(ctx.doc, 'block_comment') or { })[1] end,
BLOCK_COMMENT_END = function(ctx) return (doc_syntax(ctx.doc, 'block_comment') or { })[2] end,
LINE_COMMENT = function(ctx) return doc_syntax(ctx.doc, 'comment') end
-- https://code.visualstudio.com/docs/editor/userdefinedsnippets#_variables
-- UUID
}
local formatters; formatters = {
downcase = string.lower,
upcase = string.upper,
capitalize = function(str)
return str:sub(1, 1):upper() .. str:sub(2)
end,
pascalcase = function(str)
local t = { }
for s in str:gmatch('%w+') do
table.insert(t, formatters.capitalize(s))
end
return table.concat(t)
end,
camelcase = function(str)
str = formatters.pascalcase(str)
return str:sub(1, 1):lower() .. str:sub(2)
end
}
local function to_text(v, _s)
return v.esc
end
local function format_fn(v, _s)
local id = tonumber(v[2])
-- $1 | ${1}
if #v < 4 then
return function(captures)
return captures[id] or ''
end
end
-- ${1:...}
local t = v[3][2][1] -- token after the ':' | (else when no token)
local i = v[3][2][2] -- formatter | if | (else when no if)
local e = v[3][2][4] -- (else when if)
if t == '/' then
local f = formatters[i]
return function(captures)
local c = captures[id]
return c and f(c) or ''
end
elseif t == '+' then
return function(captures)
return captures[id] and i or ''
end
elseif t == '?' then
return function(captures)
return captures[id] and i or e
end
elseif t == '-' then
return function(captures)
return captures[id] or i
end
else
return function(captures)
return captures[id] or t
end
end
end
local function transform_fn(v, _s)
local reg = regex.compile(v[2], v[#v])
local fmt = v[4]
if type(fmt) ~= 'table' then
return function(str)
return reg:gsub(str, '')
end
end
local t = { }
for _, f in ipairs(fmt) do
if type(f) == 'string' then
table.insert(t, f)
else
break
end
end
if #t == #fmt then
t = table.concat(t)
return function(str)
return reg:gsub(str, t)
end
end
return function(str)
local captures = { reg:match(str) }
for k, v in ipairs(captures) do
if type(v) ~= 'string' then
captures[k] = nil
end
end
local t = { }
for _, f in ipairs(fmt) do
if type(f) == 'string' then
table.insert(t, f)
else
table.insert(t, f(captures))
end
end
return table.concat(t)
end
end
local function text_node(v, _s)
return B.static(v.esc)
end
local function variable_node(v, _s)
local name = v[2]
local var = variables[name]
local id
if not var then
if not _s._converted_variables then
id = os.time()
_s._converted_variables = { [name] = id, [LAST_CONVERTED_ID] = id }
else
id = _s._converted_variables[name]
if not id then
id = _s._converted_variables[LAST_CONVERTED_ID] + 1
_s._converted_variables[name] = id
_s._converted_variables[LAST_CONVERTED_ID] = id
end
end
end
if #v ~= 4 then
return var and B.static(var) or B.user(id, name)
end
if type(v[3]) == 'table' then
-- vscode accepts empty default -> var name
return var and B.static(var) or B.user(id, v[3][2] or name)
end
if not var then
return B.user(id, nil, v[3])
end
return type(var) ~= 'function' and B.static(var) or B.static(function(ctx)
return v[3](var(ctx))
end)
end
local function tabstop_node(v, _s)
local t = v[3] and v[3] ~= '}' and v[3] or nil
return B.user(tonumber(v[2]), nil, t)
end
local function choice_node(v, _s)
local id = tonumber(v[2])
local c = { [v[4]] = true }
if #v == 6 then
for _, _c in ipairs(v[5]) do
c[_c[2]] = true
end
end
_s:choice(id, c)
return B.user(id)
end
local function placeholder_node(v, _s)
local id = tonumber(v[2])
_s:default(id, v[4])
return B.user(id)
end
local function build_snippet(v, _s)
for _, n in ipairs(v) do _s:add(n) end
return _s:ok()
end
-- parser metatable
local P do
local mt = {
__call = function(mt, parser, converter)
return setmetatable({ parser = parser, converter = converter }, mt)
end,
-- allows 'lazy arguments'
-- i.e can use a yet to be defined rule in a previous rule
__index = function(t, k)
return function(...) return t[k](...) end
end
}
P = setmetatable({
__call = function(t, str, at, _s)
local r = t.parser(str, at, _s)
if r.ok and t.converter then
r.value = t.converter(r.value, _s)
end
return r
end
}, mt)
end
-- utils
local function toset(t)
local r = { }
for _, v in pairs(t or { }) do
r[v] = true
end
return r
end
local function fail(at)
return { at = at }
end
local function ok(at, v)
return { ok = true, at = at, value = v }
end
-- base + combinators
local function token(t)
return function(str, at)
local to = at + #t
return t == str:sub(at, to - 1) and ok(to, t) or fail(at)
end
end
local function consume(stops, escapes)
stops, escapes = toset(stops), toset(escapes)
return function(str, at)
local to = at
local raw, esc = { }, { }
local c = str:sub(to, to)
while to <= #str and not stops[c] do
if c == '\\' then
table.insert(raw, c)
to = to + 1
c = str:sub(to, to)
if not stops[c] and not escapes[c] then
table.insert(esc, '\\')
end
end
table.insert(raw, c)
table.insert(esc, c)
to = to + 1
c = str:sub(to, to)
end
return to ~= at
and ok(to, { raw = table.concat(raw), esc = table.concat(esc) })
or fail(at)
end
end
local function pattern(p)
return function(str, at)
local r = str:match('^' .. p, at)
return r and ok(at + #r, r) or fail(at)
end
end
local function maybe(p)
return function(str, at, ...)
local r = p(str, at, ...)
return ok(r.at, r.value)
end
end
local function rep(p)
return function(str, at, ...)
local v, to, r = { }, at, ok(at)
while to <= #str and r.ok do
table.insert(v, r.value)
to = r.at
r = p(str, to, ...)
end
return #v > 0 and ok(to, v) or fail(at)
end
end
local function any(...)
local t = { ... }
return function(str, at, ...)
for _, p in ipairs(t) do
local r = p(str, at, ...)
if r.ok then return r end
end
return fail(at)
end
end
local function seq(...)
local t = { ... }
return function(str, at, ...)
local v, to = { }, at
for _, p in ipairs(t) do
local r = p(str, to, ...)
if r.ok then
table.insert(v, r.value)
to = r.at
else
return fail(at)
end
end
return ok(to, v)
end
end
-- grammar rules
-- token cache
local t = setmetatable({ },
{
__index = function(t, k)
local fn = token(k)
rawset(t, k, fn)
return fn
end
}
)
P.int = pattern('%d+')
P.var = pattern('[%a_][%w_]*')
-- '}' needs to be escaped in normal text (i.e #0)
local __text0 = consume({ '$' }, { '\\', '}' })
local __text1 = consume({ '}' }, { '\\' })
local __text2 = consume({ ':' }, { '\\' })
local __text3 = consume({ '/' }, { '\\' })
local __text4 = consume({ '$', '}' }, { '\\' })
local __text5 = consume({ ',', '|' }, { '\\' })
local __text6 = consume({ "$", "/" }, { "\\" })
P._if1 = P(__text1, to_text)
P._if2 = P(__text2, to_text)
P._else = P(__text1, to_text)
P.options = pattern('%l*')
P.regex = P(__text3, to_text)
P.format = P(any(
seq(t['$'], P.int),
seq(t['${'], P.int, maybe(seq(t[':'], any(
seq(t['/'], any(t['upcase'], t['downcase'], t['capitalize'], t['pascalcase'], t['camelcase'])),
seq(t['+'], P._if1),
seq(t['?'], P._if2, t[':'], P._else),
seq(t['-'], P._else),
P._else
))), t['}'])
), format_fn)
P.transform_text = P(__text6, to_text)
P.transform = P(
seq(t['/'], P.regex, t['/'], rep(any(P.format, P.transform_text)), t['/'], P.options),
transform_fn
)
P.variable_text = P(__text4, text_node)
P.variable = P(any(
seq(t['$'], P.var),
seq(t['${'], P.var, maybe(any(
-- grammar says a single mandatory 'any' for default, vscode seems to accept any*
seq(t[':'], maybe(rep(any(P.dollars, P.variable_text)))),
P.transform
)), t['}'])
), variable_node)
P.choice_text = P(__text5, to_text)
P.choice = P(
seq(t['${'], P.int, t['|'], P.choice_text, maybe(rep(seq(t[','], P.choice_text))), t['|}']),
choice_node
)
P.placeholder_text = P(__text4, text_node)
P.placeholder = P(
seq(t['${'], P.int, t[':'], maybe(rep(any(P.dollars, P.placeholder_text))), t['}']),
placeholder_node
)
P.tabstop = P(any(
seq(t['$'], P.int),
-- transform isnt specified in the grammar but seems to be supported by vscode
seq(t['${'], P.int, maybe(P.transform), t['}'])
), tabstop_node)
P.dollars = any(P.tabstop, P.placeholder, P.choice, P.variable)
P.text = P(__text0, text_node)
P.any = any(P.dollars, P.text)
P.snippet = P(rep(P.any), build_snippet)
-- JSON files
-- defined at the end of the file
local extensions
local fstate = { NOT_DONE = 'not done', QUEUED = 'queued', DONE = 'done' }
local queue = { }
local files = { }
local files2exts = { }
local exts2files = { }
local function parse_file(file)
if files[file] == fstate.DONE then return end
files[file] = fstate.DONE
local _f = io.open(file)
if not _f then
core.error('[LSP snippets] Could not open \'%s\'', file)
return
end
local ok, r = pcall(json.decode, _f:read('a'))
_f:close()
if not ok then
core.error('[LSP snippets] %s: %s', file, r:match('%d+:%s+(.*)'))
return false
end
local exts = file:match('%.json$') and files2exts[file]
for i, s in pairs(r) do
-- apparently body can be a single string
local template = type(s.body) == 'table'
and table.concat(s.body, '\n')
or s.body
if not template or template == '' then
core.warn('[LSP snippets] missing \'body\' for %s (%s)', i, file)
goto continue
end
-- https://code.visualstudio.com/docs/editor/userdefinedsnippets#_language-snippet-scope
local scope
if not exts and s.scope then
local tmp = { }
for _, l in ipairs(s.scope) do
for _, e in ipairs(extensions[l:lower()]) do
tmp[e] = true
end
end
scope = { }
for l in pairs(tmp) do
table.insert(scope, l)
end
end
-- prefix may be an array
local triggers = type(s.prefix) ~= 'table' and { s.prefix } or s.prefix
if #triggers == 0 then
core.warn('[LSP snippets] missing \'prefix\' for %s (%s)', i, file)
goto continue
end
for _, t in ipairs(triggers) do
snippets.add {
trigger = t,
format = 'lsp',
files = exts or scope,
info = i,
desc = s.description,
template = template
}
end
::continue::
end
return true
end
local function pop()
while #queue > 0 do
repeat until parse_file(table.remove(queue)) ~= nil
if #queue > 0 then coroutine.yield() end
end
end
local function enqueue(filename)
if not core.threads[THREAD_KEY] then
core.add_thread(pop, THREAD_KEY)
end
files[filename] = fstate.QUEUED
table.insert(queue, filename)
end
local function add_file(filename, exts)
if files[filename] then return end
if filename:match('%.code%-snippets$') then
enqueue(filename)
return
end
if not filename:match('%.json$') then return end
if not exts then
local lang_name = filename:match('([^/%\\]*)%.%w*$'):lower()
exts = extensions[lang_name]
if not exts then return end
end
files[filename] = fstate.NOT_DONE
exts = type(exts) == 'string' and { exts } or exts
for _, e in ipairs(exts) do
files2exts[filename] = files2exts[filename] or { }
table.insert(files2exts[filename], '%.' .. e .. '$')
exts2files[e] = exts2files[e] or { }
table.insert(exts2files[e], filename)
end
end
local function for_filename(name)
if not name then return end
local ext = name:match('%.(.*)$')
if not ext then return end
local _files = exts2files[ext]
if not _files then return end
for _, f in ipairs(_files) do
if files[f] == fstate.NOT_DONE then
enqueue(f)
end
end
end
local doc_new = Doc.new
function Doc:new(filename, ...)
doc_new(self, filename, ...)
for_filename(filename)
end
local doc_set_filename = Doc.set_filename
function Doc:set_filename(filename, ...)
doc_set_filename(self, filename, ...)
for_filename(filename)
end
-- API
local M = { }
function M.parse(template)
local _s = B.new()
local r = P.snippet(template, 1, _s)
if not r.ok then
return B.new():s(template):ok()
elseif r.at == #template + 1 then
return r.value
else
return _s:s(template:sub(r.at + 1)):ok()
end
end
snippets.parsers.lsp = M.parse
local warned = false
function M.add_paths(paths)
if not json then
if not warned then
core.error(
'[LSP snippets] Could not add snippet file(s):' ..
'JSON plugin not found'
)
warned = true
end
return
end
paths = type(paths) ~= 'table' and { paths } or paths
for _, p in ipairs(paths) do
-- non absolute paths are treated as relative from USERDIR
p = not common.is_absolute_path(p) and (USERDIR .. PATHSEP .. p) or p
local finfo = system.get_file_info(p)
-- if path of a directory, add every file it contains and directories
-- whose name is that of a lang
if finfo and finfo.type == 'dir' then
for _, f in ipairs(system.list_dir(p)) do
f = p .. PATHSEP .. f
finfo = system.get_file_info(f)
if not finfo or finfo.type == 'file' then
add_file(f)
else
-- only if the directory's name matches a language
local lang_name = f:match('[^/%\\]*$'):lower()
local exts = extensions[lang_name]
for _, f2 in ipairs(system.list_dir(f)) do
f2 = f .. PATHSEP .. f2
finfo = system.get_file_info(f2)
if not finfo or finfo.type == 'file' then
add_file(f2, exts)
end
end
end
end
-- if path of a file, add the file
else
add_file(p)
end
end
end
-- arbitrarily cleaned up extension dump from https://gist.github.com/ppisarczyk/43962d06686722d26d176fad46879d41
-- nothing after this
-- 90% of these are still useless but cba
extensions = {
['ats'] = { 'dats', 'hats', 'sats', },
['ada'] = { 'adb', 'ada', 'ads', },
['agda'] = { 'agda', },
['asciidoc'] = { 'asciidoc', 'adoc', 'asc', },
['assembly'] = { 'asm', 'nasm', },
['autohotkey'] = { 'ahk', 'ahkl', },
['awk'] = { 'awk', 'auk', 'gawk', 'mawk', 'nawk', },
['batchfile'] = { 'bat', 'cmd', },
['c'] = { 'c', 'h', },
['c#'] = { 'cs', 'cake', 'cshtml', 'csx', },
['c++'] = { 'cpp', 'c++', 'cc', 'cp', 'cxx', 'h', 'h++', 'hh', 'hpp', 'hxx', },
['cmake'] = { 'cmake', 'cmake.in', },
['cobol'] = { 'cob', 'cbl', 'ccp', 'cobol', 'cpy', },
['css'] = { 'css', },
['clean'] = { 'icl', 'dcl', },
['clojure'] = { 'clj', 'boot', 'cl2', 'cljc', 'cljs', 'cljs.hl', 'cljscm', 'cljx', 'hic', },
['common lisp'] = { 'lisp', 'asd', 'cl', 'l', 'lsp', 'ny', 'podsl', 'sexp', },
['component pascal'] = { 'cp', 'cps', },
['coq'] = { 'coq', 'v', },
['crystal'] = { 'cr', },
['cuda'] = { 'cu', 'cuh', },
['d'] = { 'd', 'di', },
['dart'] = { 'dart', },
['dockerfile'] = { 'dockerfile', },
['eiffel'] = { 'e', },
['elixir'] = { 'ex', 'exs', },
['elm'] = { 'elm', },
['emacs lisp'] = { 'el', 'emacs', 'emacs.desktop', },
['erlang'] = { 'erl', 'es', 'escript', 'hrl', 'xrl', 'yrl', },
['f#'] = { 'fs', 'fsi', 'fsx', },
['fortran'] = { 'f90', 'f', 'f03', 'f08', 'f77', 'f95', 'for', 'fpp', },
['factor'] = { 'factor', },
['forth'] = { 'fth', '4th', 'f', 'for', 'forth', 'fr', 'frt', 'fs', },
['go'] = { 'go', },
['groff'] = { 'man', '1', '1in', '1m', '1x', '2', '3', '3in', '3m', '3qt', '3x', '4', '5', '6', '7', '8', '9', 'l', 'me', 'ms', 'n', 'rno', 'roff', },
['groovy'] = { 'groovy', 'grt', 'gtpl', 'gvy', },
['html'] = { 'html', 'htm', 'html.hl', 'xht', 'xhtml', },
['haskell'] = { 'hs', 'hsc', },
['idris'] = { 'idr', 'lidr', },
['jsx'] = { 'jsx', },
['java'] = { 'java', },
['javascript'] = { 'js', },
['julia'] = { 'jl', },
['jupyter notebook'] = { 'ipynb', },
['kotlin'] = { 'kt', 'ktm', 'kts', },
['lean'] = { 'lean', 'hlean', },
['less'] = { 'less', },
['lua'] = { 'lua', 'fcgi', 'nse', 'pd_lua', 'rbxs', 'wlua', },
['markdown'] = { 'md', 'markdown', 'mkd', 'mkdn', 'mkdown', 'ron', },
['modula-2'] = { 'mod', },
['moonscript'] = { 'moon', },
['ocaml'] = { 'ml', 'eliom', 'eliomi', 'ml4', 'mli', 'mll', 'mly', },
['objective-c'] = { 'm', 'h', },
['objective-c++'] = { 'mm', },
['oz'] = { 'oz', },
['php'] = { 'php', 'aw', 'ctp', 'fcgi', 'inc', 'php3', 'php4', 'php5', 'phps', 'phpt', },
['plsql'] = { 'pls', 'pck', 'pkb', 'pks', 'plb', 'plsql', 'sql', },
['plpgsql'] = { 'sql', },
['pascal'] = { 'pas', 'dfm', 'dpr', 'inc', 'lpr', 'pp', },
['perl'] = { 'pl', 'al', 'cgi', 'fcgi', 'perl', 'ph', 'plx', 'pm', 'pod', 'psgi', 't', },
['perl6'] = { '6pl', '6pm', 'nqp', 'p6', 'p6l', 'p6m', 'pl', 'pl6', 'pm', 'pm6', 't', },
['picolisp'] = { 'l', },
['pike'] = { 'pike', 'pmod', },
['pony'] = { 'pony', },
['postscript'] = { 'ps', 'eps', },
['powershell'] = { 'ps1', 'psd1', 'psm1', },
['prolog'] = { 'pl', 'pro', 'prolog', 'yap', },
['python'] = { 'py', 'bzl', 'cgi', 'fcgi', 'gyp', 'lmi', 'pyde', 'pyp', 'pyt', 'pyw', 'rpy', 'tac', 'wsgi', 'xpy', },
['racket'] = { 'rkt', 'rktd', 'rktl', 'scrbl', },
['rebol'] = { 'reb', 'r', 'r2', 'r3', 'rebol', },
['ruby'] = { 'rb', 'builder', 'fcgi', 'gemspec', 'god', 'irbrc', 'jbuilder', 'mspec', 'pluginspec', 'podspec', 'rabl', 'rake', 'rbuild', 'rbw', 'rbx', 'ru', 'ruby', 'thor', 'watchr', },
['rust'] = { 'rs', 'rs.in', },
['scss'] = { 'scss', },
['sql'] = { 'sql', 'cql', 'ddl', 'inc', 'prc', 'tab', 'udf', 'viw', },
['sqlpl'] = { 'sql', 'db2', },
['scala'] = { 'scala', 'sbt', 'sc', },
['scheme'] = { 'scm', 'sld', 'sls', 'sps', 'ss', },
['self'] = { 'self', },
['shell'] = { 'sh', 'bash', 'bats', 'cgi', 'command', 'fcgi', 'ksh', 'sh.in', 'tmux', 'tool', 'zsh', },
['smalltalk'] = { 'st', 'cs', },
['standard ml'] = { 'ML', 'fun', 'sig', 'sml', },
['swift'] = { 'swift', },
['tcl'] = { 'tcl', 'adp', 'tm', },
['tex'] = { 'tex', 'aux', 'bbx', 'bib', 'cbx', 'cls', 'dtx', 'ins', 'lbx', 'ltx', 'mkii', 'mkiv', 'mkvi', 'sty', 'toc', },
['typescript'] = { 'ts', 'tsx', },
['vala'] = { 'vala', 'vapi', },
['verilog'] = { 'v', 'veo', },
['visual basic'] = { 'vb', 'bas', 'cls', 'frm', 'frx', 'vba', 'vbhtml', 'vbs', },
}
extensions.cpp = extensions['c++']
extensions.csharp = extensions['c#']
extensions.latex = extensions.tex
extensions.objc = extensions['objective-c']
M.extensions = extensions
return M