-- 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]) if #v > 4 then _s:default(id, v[4]) end 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