local fs = require 'bee.filesystem' local nonil = require 'without-check-nil' local util = require 'utility' local lang = require 'language' local proto = require 'proto' local define = require 'proto.define' local config = require 'config' local converter = require 'proto.converter' local await = require 'await' local scope = require 'workspace.scope' local inspect = require 'inspect' local jsone = require 'json-edit' local jsonc = require 'jsonc' local m = {} m._eventList = {} function m.client(newClient) if newClient then m._client = newClient else return m._client end end function m.isVSCode() if not m._client then return false end if m._isvscode == nil then local lname = m._client:lower() if lname:find 'vscode' or lname:find 'visual studio code' then m._isvscode = true else m._isvscode = false end end return m._isvscode end function m.getOption(name) nonil.enable() local option = m.info.initializationOptions[name] nonil.disable() return option end function m.getAbility(name) if not m.info or not m.info.capabilities then return nil end local current = m.info.capabilities while true do local parent, nextPos = name:match '^([^%.]+)()' if not parent then break end current = current[parent] if not current then return current end if nextPos > #name then break else name = name:sub(nextPos + 1) end end return current end function m.getOffsetEncoding() if m._offsetEncoding then return m._offsetEncoding end local clientEncodings = m.getAbility 'offsetEncoding' if type(clientEncodings) == 'table' then for _, encoding in ipairs(clientEncodings) do if encoding == 'utf-8' then m._offsetEncoding = 'utf-8' return m._offsetEncoding end end end m._offsetEncoding = 'utf-16' return m._offsetEncoding end local function packMessage(...) local strs = table.pack(...) for i = 1, strs.n do strs[i] = tostring(strs[i]) end return table.concat(strs, '\t') end ---@alias message.type '"Error"'|'"Warning"'|'"Info"'|'"Log"' ---show message to client ---@param type message.type function m.showMessage(type, ...) local message = packMessage(...) proto.notify('window/showMessage', { type = define.MessageType[type] or 3, message = message, }) proto.notify('window/logMessage', { type = define.MessageType[type] or 3, message = message, }) log.info('ShowMessage', type, message) end ---@param type message.type ---@param message string ---@param titles string[] ---@param callback fun(action?: string, index?: integer) function m.requestMessage(type, message, titles, callback) proto.notify('window/logMessage', { type = define.MessageType[type] or 3, message = message, }) local map = {} local actions = {} for i, title in ipairs(titles) do actions[i] = { title = title, } map[title] = i end log.info('requestMessage', type, message) proto.request('window/showMessageRequest', { type = define.MessageType[type] or 3, message = message, actions = actions, }, function (item) log.info('responseMessage', message, item and item.title or nil) if item then callback(item.title, map[item.title]) else callback(nil, nil) end end) end ---@param type message.type ---@param message string ---@param titles string[] ---@return string action ---@return integer index ---@async function m.awaitRequestMessage(type, message, titles) return await.wait(function (waker) m.requestMessage(type, message, titles, waker) end) end ---@param type message.type function m.logMessage(type, ...) local message = packMessage(...) proto.notify('window/logMessage', { type = define.MessageType[type] or 4, message = message, }) end function m.watchFiles(path) path = path:gsub('\\', '/') :gsub('[%[%]%{%}%*%?]', '\\%1') local registration = { id = path, method = 'workspace/didChangeWatchedFiles', registerOptions = { watchers = { { globPattern = path .. '/**', kind = 1 | 2 | 4, }, }, }, } proto.request('client/registerCapability', { registrations = { registration, } }) return function () local unregisteration = { id = path, method = 'workspace/didChangeWatchedFiles', } proto.request('client/registerCapability', { unregisterations = { unregisteration, } }) end end ---@class config.change ---@field key string ---@field prop? string ---@field value any ---@field action '"add"'|'"set"'|'"prop"' ---@field global? boolean ---@field uri? uri ---@param uri uri? ---@param changes config.change[] ---@return config.change[] local function getValidChanges(uri, changes) local newChanges = {} if not uri then return changes end local scp = scope.getScope(uri) for _, change in ipairs(changes) do if scp:isChildUri(change.uri) or scp:isLinkedUri(change.uri) then newChanges[#newChanges+1] = change end end return newChanges end ---@class json.patch ---@field op 'add' | 'remove' | 'replace' ---@field path string ---@field value any ---@class json.patchInfo ---@field key string ---@field value any ---@param cfg table ---@param rawKey string ---@return json.patchInfo local function searchPatchInfo(cfg, rawKey) ---@param key string ---@param parentKey string ---@param parentValue table ---@return json.patchInfo? local function searchOnce(key, parentKey, parentValue) if parentValue == nil then return nil end if type(parentValue) ~= 'table' then return { key = parentKey, value = parentValue, } end if parentValue[key] then return { key = parentKey .. '/' .. key, value = parentValue[key], } end for pos in key:gmatch '()%.' do local k = key:sub(1, pos - 1) local v = parentValue[k] local info = searchOnce(key:sub(pos + 1), parentKey .. '/' .. k, v) if info then return info end end return nil end return searchOnce(rawKey, '', cfg) or searchOnce(rawKey:gsub('^Lua%.', ''), '', cfg) or { key = '/' .. rawKey:gsub('^Lua%.', ''), value = nil, } end ---@param uri? uri ---@param cfg table ---@param change config.change ---@return json.patch? local function makeConfigPatch(uri, cfg, change) local info = searchPatchInfo(cfg, change.key) if change.action == 'add' then if type(info.value) == 'table' and #info.value > 0 then return { op = 'add', path = info.key .. '/-', value = change.value, } else return makeConfigPatch(uri, cfg, { action = 'set', key = change.key, value = config.get(uri, change.key), }) end elseif change.action == 'set' then if info.value ~= nil then return { op = 'replace', path = info.key, value = change.value, } else return { op = 'add', path = info.key, value = change.value, } end elseif change.action == 'prop' then if type(info.value) == 'table' and next(info.value) then return { op = 'add', path = info.key .. '/' .. change.prop, value = change.value, } else return makeConfigPatch(uri, cfg, { action = 'set', key = change.key, value = config.get(uri, change.key), }) end end return nil end ---@param uri? uri ---@param path string ---@param changes config.change[] ---@return string? local function editConfigJson(uri, path, changes) local text = util.loadFile(path) if not text then m.showMessage('Error', lang.script('CONFIG_LOAD_FAILED', path)) return nil end local suc, res = pcall(jsonc.decode_jsonc, text) if not suc then m.showMessage('Error', lang.script('CONFIG_MODIFY_FAIL_SYNTAX_ERROR', path .. res:match 'ERROR(.+)$')) return nil end if type(res) ~= 'table' then res = {} end ---@cast res table for _, change in ipairs(changes) do local patch = makeConfigPatch(uri, res, change) if patch then text = jsone.edit(text, patch, { indent = ' ' }) end end return text end ---@param changes config.change[] ---@param applied config.change[] local function removeAppliedChanges(changes, applied) local appliedMap = {} for _, change in ipairs(applied) do appliedMap[change] = true end for i = #changes, 1, -1 do if appliedMap[changes[i]] then table.remove(changes, i) end end end local function tryModifySpecifiedConfig(uri, finalChanges) if #finalChanges == 0 then return false end log.info('tryModifySpecifiedConfig', uri, inspect(finalChanges)) local workspace = require 'workspace' local scp = scope.getScope(uri) if scp:get('lastLocalType') ~= 'json' then log.info('lastLocalType ~= json') return false end local validChanges = getValidChanges(uri, finalChanges) if #validChanges == 0 then log.info('No valid changes') return false end local path = workspace.getAbsolutePath(uri, CONFIGPATH) if not path then log.info('Can not get absolute path') return false end local newJson = editConfigJson(uri, path, validChanges) if not newJson then log.info('Can not edit config json') return false end util.saveFile(path, newJson) log.info('Apply changes to config file', inspect(validChanges)) removeAppliedChanges(finalChanges, validChanges) return true end local function tryModifyRC(uri, finalChanges, create) if #finalChanges == 0 then return false end log.info('tryModifyRC', uri, inspect(finalChanges)) local workspace = require 'workspace' local path = workspace.getAbsolutePath(uri, '.luarc.jsonc') if not path then log.info('Can not get absolute path of .luarc.jsonc') return false end path = fs.exists(fs.path(path)) and path or workspace.getAbsolutePath(uri, '.luarc.json') if not path then log.info('Can not get absolute path of .luarc.json') return false end local buf = util.loadFile(path) if not buf and not create then log.info('Can not load .luarc.json and not create') return false end local validChanges = getValidChanges(uri, finalChanges) if #validChanges == 0 then log.info('No valid changes') return false end if not buf then util.saveFile(path, '') end local newJson = editConfigJson(uri, path, validChanges) if not newJson then log.info('Can not edit config json') return false end util.saveFile(path, newJson) log.info('Apply changes to .luarc.json', inspect(validChanges)) removeAppliedChanges(finalChanges, validChanges) return true end local function tryModifyClient(uri, finalChanges) if #finalChanges == 0 then return false end log.info('tryModifyClient', uri, inspect(finalChanges)) if not m.getOption 'changeConfiguration' then return false end local scp = scope.getScope(uri) local scpChanges = {} for _, change in ipairs(finalChanges) do if change.uri and (scp:isChildUri(change.uri) or scp:isLinkedUri(change.uri)) then scpChanges[#scpChanges+1] = change end end if #scpChanges == 0 then log.info('No changes in client scope') return false end proto.notify('$/command', { command = 'lua.config', data = scpChanges, }) log.info('Apply client changes', uri, inspect(scpChanges)) removeAppliedChanges(finalChanges, scpChanges) return true end ---@param finalChanges config.change[] local function tryModifyClientGlobal(finalChanges) if #finalChanges == 0 then return end log.info('tryModifyClientGlobal', inspect(finalChanges)) if not m.getOption 'changeConfiguration' then log.info('Client dose not support modifying config') return end local changes = {} for _, change in ipairs(finalChanges) do if change.global then changes[#changes+1] = change end end if #changes == 0 then log.info('No global changes') return end proto.notify('$/command', { command = 'lua.config', data = changes, }) log.info('Apply client global changes', inspect(changes)) removeAppliedChanges(finalChanges, changes) end ---@param changes config.change[] ---@return string local function buildMaunuallyMessage(changes) local message = {} for _, change in ipairs(changes) do if change.action == 'add' then message[#message+1] = '* ' .. lang.script('WINDOW_MANUAL_CONFIG_ADD', change.key, change.value) elseif change.action == 'set' then message[#message+1] = '* ' .. lang.script('WINDOW_MANUAL_CONFIG_SET', change.key, change.value) elseif change.action == 'prop' then message[#message+1] = '* ' .. lang.script('WINDOW_MANUAL_CONFIG_PROP', change.key, change.prop, change.value) end end return table.concat(message, '\n') end ---@param changes config.change[] ---@param onlyMemory? boolean function m.setConfig(changes, onlyMemory) local finalChanges = {} for _, change in ipairs(changes) do if change.action == 'add' then local suc = config.add(change.uri, change.key, change.value) if suc then finalChanges[#finalChanges+1] = change end elseif change.action == 'set' then local suc = config.set(change.uri, change.key, change.value) if suc then finalChanges[#finalChanges+1] = change end elseif change.action == 'prop' then local suc = config.prop(change.uri, change.key, change.prop, change.value) if suc then finalChanges[#finalChanges+1] = change end end end if onlyMemory then return end if #finalChanges == 0 then return end log.info('Modify config', inspect(finalChanges)) xpcall(function () local ws = require 'workspace' tryModifyClientGlobal(finalChanges) if #ws.folders == 0 then tryModifySpecifiedConfig(nil, finalChanges) tryModifyClient(nil, finalChanges) if #finalChanges > 0 then local manuallyModifyConfig = buildMaunuallyMessage(finalChanges) m.showMessage('Warning', lang.script('CONFIG_MODIFY_FAIL_NO_WORKSPACE', manuallyModifyConfig)) end else for _, scp in ipairs(ws.folders) do tryModifySpecifiedConfig(scp.uri, finalChanges) tryModifyRC(scp.uri, finalChanges, false) tryModifyClient(scp.uri, finalChanges) tryModifyRC(scp.uri, finalChanges, true) end if #finalChanges > 0 then m.showMessage('Warning', lang.script('CONFIG_MODIFY_FAIL', buildMaunuallyMessage(finalChanges))) log.warn('Config modify fail', inspect(finalChanges)) end end end, log.error) end ---@alias textEditor {start: integer, finish: integer, text: string} ---@param uri uri ---@param edits textEditor[] function m.editText(uri, edits) local files = require 'files' local state = files.getState(uri) if not state then return end local textEdits = {} for i, edit in ipairs(edits) do textEdits[i] = converter.textEdit(converter.packRange(state, edit.start, edit.finish), edit.text) end local params = { edit = { changes = { [uri] = textEdits, } } } proto.request('workspace/applyEdit', params) log.info('workspace/applyEdit', inspect(params)) end ---@alias textMultiEditor {uri: uri, start: integer, finish: integer, text: string} ---@param editors textMultiEditor[] function m.editMultiText(editors) local files = require 'files' local changes = {} for _, editor in ipairs(editors) do local uri = editor.uri local state = files.getState(uri) if state then if not changes[uri] then changes[uri] = {} end local edit = converter.textEdit(converter.packRange(state, editor.start, editor.finish), editor.text) table.insert(changes[uri], edit) end end local params = { edit = { changes = changes, } } proto.request('workspace/applyEdit', params) log.info('workspace/applyEdit', inspect(params)) end ---@param callback async fun(ev: string) function m.event(callback) m._eventList[#m._eventList+1] = callback end function m._callEvent(ev) for _, callback in ipairs(m._eventList) do await.call(function () callback(ev) end) end end function m.setReady() m._ready = true m._callEvent('ready') end function m.isReady() return m._ready == true end local function hookPrint() if TEST or CLI then return end print = function (...) m.logMessage('Log', ...) end end function m.init(t) log.info('Client init', inspect(t)) m.info = t nonil.enable() m.client(t.clientInfo.name) nonil.disable() lang(LOCALE or t.locale) converter.setOffsetEncoding(m.getOffsetEncoding()) hookPrint() m._callEvent('init') end return m