local await = require 'await' local proto = require 'proto.proto' local define = require 'proto.define' local lang = require 'language' local files = require 'files' local config = require 'config' local core = require 'core.diagnostics' local util = require 'utility' local ws = require 'workspace' local progress = require "progress" local client = require 'client' local converter = require 'proto.converter' local loading = require 'workspace.loading' local scope = require 'workspace.scope' local time = require 'bee.time' local ltable = require 'linked-table' local furi = require 'file-uri' local json = require 'json' local fw = require 'filewatch' ---@class diagnosticProvider local m = {} m.cache = {} m.sleepRest = 0.0 local function concat(t, sep) if type(t) ~= 'table' then return t end return table.concat(t, sep) end local function buildSyntaxError(uri, err) local text = files.getText(uri) local message = lang.script('PARSER_' .. err.type, err.info) if err.version then local version = err.info and err.info.version or config.get(uri, 'Lua.runtime.version') message = message .. ('(%s)'):format(lang.script('DIAG_NEED_VERSION' , concat(err.version, '/') , version )) end local related = err.info and err.info.related local relatedInformation if related then relatedInformation = {} for _, rel in ipairs(related) do local rmessage if rel.message then rmessage = lang.script('PARSER_' .. rel.message) else rmessage = text:sub(rel.start, rel.finish) end local relUri = rel.uri or uri relatedInformation[#relatedInformation+1] = { message = rmessage, location = converter.location(relUri, converter.packRange(relUri, rel.start, rel.finish)), } end end return { code = err.type:lower():gsub('_', '-'), range = converter.packRange(uri, err.start, err.finish), severity = define.DiagnosticSeverity[err.level], source = lang.script.DIAG_SYNTAX_CHECK, message = message, data = 'syntax', relatedInformation = relatedInformation, } end local function buildDiagnostic(uri, diag) if not files.exists(uri) then return end local relatedInformation if diag.related then relatedInformation = {} for _, rel in ipairs(diag.related) do local rtext = files.getText(rel.uri) relatedInformation[#relatedInformation+1] = { message = rel.message or rtext:sub(rel.start, rel.finish), location = converter.location(rel.uri, converter.packRange(rel.uri, rel.start, rel.finish)) } end end return { range = converter.packRange(uri, diag.start, diag.finish), source = lang.script.DIAG_DIAGNOSTICS, severity = diag.level, message = diag.message, code = diag.code, tags = diag.tags, data = diag.data, relatedInformation = relatedInformation, } end local function mergeDiags(a, b, c) if not a and not b and not c then return nil end local t = {} local function merge(diags) if not diags then return end for i = 1, #diags do local diag = diags[i] local severity = diag.severity if severity == define.DiagnosticSeverity.Hint or severity == define.DiagnosticSeverity.Information then if #t > 10000 then goto CONTINUE end end t[#t+1] = diag ::CONTINUE:: end end merge(a) merge(b) merge(c) if #t == 0 then return nil end return t end -- enable `push`, disable `clear` function m.clear(uri, force) await.close('diag:' .. uri) if m.cache[uri] == nil and not force then return end m.cache[uri] = nil proto.notify('textDocument/publishDiagnostics', { uri = uri, diagnostics = {}, }) log.info('clearDiagnostics', uri) end -- enable `push` and `send` function m.clearCache(uri) m.cache[uri] = false end function m.clearCacheExcept(uris) local excepts = {} for _, uri in ipairs(uris) do excepts[uri] = true end for uri in pairs(m.cache) do if not excepts[uri] then m.cache[uri] = false end end end function m.clearAll(force) if force then for luri in files.eachFile() do m.clear(luri, force) end else for luri in pairs(m.cache) do m.clear(luri) end end end function m.syntaxErrors(uri, ast) if #ast.errs == 0 then return nil end local results = {} pcall(function () local disables = util.arrayToHash(config.get(uri, 'Lua.diagnostics.disable')) for _, err in ipairs(ast.errs) do if not disables[err.type:lower():gsub('_', '-')] then results[#results+1] = buildSyntaxError(uri, err) end end end) return results end local function copyDiagsWithoutSyntax(diags) if not diags then return nil end local copyed = {} for _, diag in ipairs(diags) do if diag.data ~= 'syntax' then copyed[#copyed+1] = diag end end return copyed end ---@async ---@param uri uri ---@return boolean local function isValid(uri) if not config.get(uri, 'Lua.diagnostics.enable') then return false end if files.isLibrary(uri, true) then local status = config.get(uri, 'Lua.diagnostics.libraryFiles') if status == 'Disable' then return false elseif status == 'Opened' then if not files.isOpen(uri) then return false end end end if ws.isIgnored(uri) then local status = config.get(uri, 'Lua.diagnostics.ignoredFiles') if status == 'Disable' then return false elseif status == 'Opened' then if not files.isOpen(uri) then return false end end end local scheme = furi.split(uri) local disableScheme = config.get(uri, 'Lua.diagnostics.disableScheme') if util.arrayHas(disableScheme, scheme) then return false end return true end ---@async function m.doDiagnostic(uri, isScopeDiag) if not isValid(uri) then return end await.delay() local state = files.getState(uri) if not state then m.clear(uri) return end local version = files.getVersion(uri) local prog = progress.create(uri, lang.script.WINDOW_DIAGNOSING, 0.5) prog:setMessage(ws.getRelativePath(uri)) --log.debug('Diagnostic file:', uri) local syntax = m.syntaxErrors(uri, state) local diags = {} local lastDiag = copyDiagsWithoutSyntax(m.cache[uri]) local function pushResult() tracy.ZoneBeginN 'mergeSyntaxAndDiags' local _ = tracy.ZoneEnd local full = mergeDiags(syntax, lastDiag, diags) --log.debug(('Pushed [%d] results'):format(full and #full or 0)) if not full then m.clear(uri) return end if util.equal(m.cache[uri], full) then return end m.cache[uri] = full proto.notify('textDocument/publishDiagnostics', { uri = uri, version = version, diagnostics = full, }) log.debug('publishDiagnostics', uri, #full) end pushResult() local lastPushClock = time.time() ---@async xpcall(core, log.error, uri, isScopeDiag, function (result) diags[#diags+1] = buildDiagnostic(uri, result) if not isScopeDiag and time.time() - lastPushClock >= 200 then lastPushClock = time.time() pushResult() end end, function (checkedName) if not lastDiag then return end for i, diag in ipairs(lastDiag) do if diag.code == checkedName then lastDiag[i] = lastDiag[#lastDiag] lastDiag[#lastDiag] = nil end end end) lastDiag = nil pushResult() end ---@async ---@return table|nil result ---@return boolean? unchanged function m.pullDiagnostic(uri, isScopeDiag) if not isValid(uri) then return nil, util.equal(m.cache[uri], nil) end await.delay() local state = files.getState(uri) if not state then return nil, util.equal(m.cache[uri], nil) end local prog = progress.create(uri, lang.script.WINDOW_DIAGNOSING, 0.5) prog:setMessage(ws.getRelativePath(uri)) local syntax = m.syntaxErrors(uri, state) local diags = {} xpcall(core, log.error, uri, isScopeDiag, function (result) diags[#diags+1] = buildDiagnostic(uri, result) end) local full = mergeDiags(syntax, diags) if util.equal(m.cache[uri], full) then return full, true end m.cache[uri] = full return full end function m.refresh(uri) if not ws.isReady(uri) then return end local scp = scope.getScope(uri) local scopeID = 'diagnosticsScope:' .. scp:getName() await.close('diag:' .. uri) await.close(scopeID) await.call(function () ---@async if uri then await.setID('diag:' .. uri) await.sleep(0.1) xpcall(m.doDiagnostic, log.error, uri) end local delay = config.get(uri, 'Lua.diagnostics.workspaceDelay') / 1000 if delay < 0 then return end await.sleep(math.max(delay, 0.2)) m.diagnosticsScope(uri) end) end ---@async local function askForDisable(uri) if m.dontAskedForDisable then return end local delay = 30 local delayTitle = lang.script('WINDOW_DELAY_WS_DIAGNOSTIC', delay) local item = proto.awaitRequest('window/showMessageRequest', { type = define.MessageType.Info, message = lang.script.WINDOW_SETTING_WS_DIAGNOSTIC, actions = { { title = lang.script.WINDOW_DONT_SHOW_AGAIN, }, { title = delayTitle, }, { title = lang.script.WINDOW_DISABLE_DIAGNOSTIC, }, } }) if not item then return end if item.title == lang.script.WINDOW_DONT_SHOW_AGAIN then m.dontAskedForDisable = true elseif item.title == delayTitle then client.setConfig { { key = 'Lua.diagnostics.workspaceDelay', action = 'set', value = delay * 1000, uri = uri, } } elseif item.title == lang.script.WINDOW_DISABLE_DIAGNOSTIC then client.setConfig { { key = 'Lua.diagnostics.workspaceDelay', action = 'set', value = -1, uri = uri, } } end end ---@async function m.awaitDiagnosticsScope(suri, callback) local scp = scope.getScope(suri) while loading.count() > 0 do await.sleep(1.0) end local clock = os.clock() local bar = progress.create(suri, lang.script.WORKSPACE_DIAGNOSTIC, 1) local cancelled bar:onCancel(function () log.info('Cancel workspace diagnostics') cancelled = true ---@async await.call(function () askForDisable(suri) end) end) local uris = files.getAllUris(suri) local sortedUris = ltable() for _, uri in ipairs(uris) do if files.isOpen(uri) then sortedUris:pushHead(uri) else sortedUris:pushTail(uri) end end log.info(('Diagnostics scope [%s], files count:[%d]'):format(scp:getName(), #uris)) local i = 0 for uri in sortedUris:pairs() do while loading.count() > 0 do await.sleep(1.0) end i = i + 1 bar:setMessage(('%d/%d'):format(i, #uris)) bar:setPercentage(i / #uris * 100) callback(uri) await.delay() if cancelled then log.info('Break workspace diagnostics') break end end bar:remove() log.info(('Diagnostics scope [%s] finished, takes [%.3f] sec.'):format(scp:getName(), os.clock() - clock)) end function m.diagnosticsScope(uri, force) if not ws.isReady(uri) then return end if not force and not config.get(uri, 'Lua.diagnostics.enable') then m.clearAll() return end local scp = scope.getScope(uri) local id = 'diagnosticsScope:' .. scp:getName() await.close(id) await.call(function () ---@async m.awaitDiagnosticsScope(uri, function (fileUri) xpcall(m.doDiagnostic, log.error, fileUri, true) end) end, id) end ---@async function m.pullDiagnosticScope(callback) local processing = 0 for _, scp in ipairs(scope.folders) do if ws.isReady(scp.uri) and config.get(scp.uri, 'Lua.diagnostics.enable') then local id = 'diagnosticsScope:' .. scp:getName() await.close(id) await.call(function () ---@async processing = processing + 1 local _ = util.defer(function () processing = processing - 1 end) local delay = config.get(scp.uri, 'Lua.diagnostics.workspaceDelay') / 1000 if delay < 0 then return end print(delay) await.sleep(math.max(delay, 0.2)) print('start') m.awaitDiagnosticsScope(scp.uri, function (fileUri) local suc, result, unchanged = xpcall(m.pullDiagnostic, log.error, fileUri, true) if suc then callback { uri = fileUri, result = result, unchanged = unchanged, version = files.getVersion(fileUri), } end end) end, id) end end -- sleep for ever while true do await.sleep(1.0) end end function m.refreshClient() log.debug('Refresh client diagnostics') proto.request('workspace/diagnostic/refresh', json.null) end ws.watch(function (ev, uri) if ev == 'reload' then m.diagnosticsScope(uri) m.refreshClient() end end) files.watch(function (ev, uri) ---@async if ev == 'remove' then m.clear(uri) m.refresh(uri) elseif ev == 'update' then m.refresh(uri) elseif ev == 'open' then if ws.isReady(uri) then m.clearCache(uri) xpcall(m.doDiagnostic, log.error, uri) end elseif ev == 'close' then if files.isLibrary(uri, true) or ws.isIgnored(uri) then m.clear(uri) end end end) config.watch(function (uri, key, value, oldValue) if key:find 'Lua.diagnostics' then if value ~= oldValue then m.diagnosticsScope(uri) m.refreshClient() end end end) fw.event(function (ev, path) if util.stringEndWith(path, '.editorconfig') then for _, scp in ipairs(ws.folders) do m.diagnosticsScope(scp.uri) m.refreshClient() end end end) return m