diff --git a/runtime/plugins/treesitter/repo.json b/runtime/plugins/treesitter/repo.json index 1f06d373..7caa2ab2 100644 --- a/runtime/plugins/treesitter/repo.json +++ b/runtime/plugins/treesitter/repo.json @@ -1,7 +1,7 @@ [ { "Name": "treesitter", - "Description": "Tree-sitter highlighting for Go through an external helper", + "Description": "Tree-sitter highlighting for Go, Python, JavaScript/TypeScript, Rust, C/C++, shell, JSON, TOML, HTML, CSS, Lua, and YAML through an external helper", "Website": "https://github.com/tree-sitter/tree-sitter" } ] diff --git a/runtime/plugins/treesitter/treesitter.lua b/runtime/plugins/treesitter/treesitter.lua index b3e38855..6e64114a 100644 --- a/runtime/plugins/treesitter/treesitter.lua +++ b/runtime/plugins/treesitter/treesitter.lua @@ -1,21 +1,46 @@ -VERSION = "0.1.0" +VERSION = "0.2.0" --- Tree-sitter highlighting for Go, driving an out-of-process helper. --- The helper owns parsing, the grammar, the query, and capture mapping; this +-- Tree-sitter highlighting for the filetypes linked into the helper. +-- The helper owns parsing, grammars, queries, and capture mapping; this -- plugin owns the process, request framing, and reply generation checks. +-- +-- One provider is registered per filetype. They all share one helper process +-- and one request function, so a buffer switch does not restart the helper. local syntax = import("micro/syntax") local shell = import("micro/shell") local micro = import("micro") local os = import("os") -local PROVIDER_NAME = "treesitter-go" -local PROVIDER_FILETYPE = "go" +-- Mirrors pkg/filetype/table.go for micro's tree-sitter filetypes. +local PROVIDERS = { + { filetype = "go", filename = [[\.go$]] }, + { filetype = "python", filename = [[\.py(3|w)?$]] }, + { filetype = "javascript", filename = [[(\.(m|c)?js$|\.es[5678]?$)]] }, + { filetype = "typescript", filename = [[\.tsx?$]] }, + { filetype = "rust", filename = [[\.rs$]] }, + { filetype = "c", filename = [[(\.(c|C)$|\.(h|H)$|\.ii?$|\.(def)$)]] }, + { + filetype = "c++", + filename = [[(\.c(c|pp|xx)$|\.h(h|pp|xx)?$|\.ii?$|\.(def)$)]], + signature = [[\b(namespace|class|public|protected|private|template|constexpr|noexcept|nullptr|throw)\b]], + }, + { + filetype = "shell", + filename = [[(\.(sh|bash|ash|ebuild)$|(\.bash(rc|_aliases|_functions|_profile)|\.?profile|Pkgfile|pkgmk\.conf|rc\.conf|PKGBUILD|APKBUILD)$|bash-fc\.)]], + }, + { filetype = "json", filename = [[\.jsonc?$]] }, + { filetype = "toml", filename = [[\.toml$]] }, + { filetype = "html", filename = [[\.htm[l]?$]] }, + { filetype = "css", filename = [[\.(css|scss)$]] }, + { filetype = "lua", filename = [[\.lua$]] }, + { filetype = "yaml", filename = [[\.ya?ml$]] }, +} local job = nil local disabled = false local logged = false -local providerRegistered = false +local providersRegistered = false local spawnGeneration = 0 local helperPath = nil @@ -24,7 +49,7 @@ local collecting = nil local replies = {} local bufferIDs = {} local nextBufferID = 0 -local lastHelperError = nil +local errorLogged = {} local function logOnce(message) if not logged then @@ -53,16 +78,16 @@ local function deliver(id, generation, spans, errMessage) end replies[id] = nil if errMessage ~= nil then - if errMessage ~= lastHelperError then - lastHelperError = errMessage - micro.Log("treesitter: helper error: " .. errMessage) + local filetype = pending.filetype or "" + if not errorLogged[filetype] then + errorLogged[filetype] = true + micro.Log("treesitter: " .. filetype .. " unavailable: " .. errMessage) end -- A failed request carries no spans. Clear the previous ones so the -- buffer is never coloured from a stale generation. pcall(pending.reply, {}) return end - lastHelperError = nil local ok, err = pcall(pending.reply, spans) if not ok then micro.Log("treesitter: reply failed: " .. tostring(err)) @@ -181,9 +206,9 @@ local function request(ctx, reply) end local id = bufferID(ctx) local generation = ctx.generation or 0 - local filetype = ctx.filetype or PROVIDER_FILETYPE + local filetype = ctx.filetype or "" local text = ctx.text or "" - replies[id] = { generation = generation, reply = reply } + replies[id] = { generation = generation, reply = reply, filetype = filetype } local frame = "REQ\t" .. id .. "\t" .. tostring(generation) .. "\t" .. filetype .. "\t" .. tostring(#text) .. "\n" .. text shell.JobSend(job, frame) @@ -212,7 +237,6 @@ local function startHelper() if #detail > 400 then detail = detail:sub(1, 400) .. "..." end - lastHelperError = detail micro.Log("treesitter: " .. detail) end @@ -240,6 +264,32 @@ local function startHelper() job = shell.JobSpawn(helperPath, {}, onStdout, onStderr, onExit) end +local function registerProviders() + local failed = false + for _, spec in ipairs(PROVIDERS) do + local ok, err = pcall(syntax.register, { + name = "treesitter-" .. spec.filetype, + filetype = spec.filetype, + filename = spec.filename, + signature = spec.signature, + priority = 100, + request = request, + }) + if not ok then + failed = true + micro.Log("treesitter: register " .. spec.filetype .. ": " .. tostring(err)) + end + end + if failed then + disabled = true + if job ~= nil then + stopJob(spawnGeneration) + end + else + providersRegistered = true + end +end + function init() if syntax == nil then logOnce("this micro build has no micro/syntax provider API") @@ -252,7 +302,7 @@ function init() disabled = false logged = false - lastHelperError = nil + errorLogged = {} helperPath = os.Getenv("MICRO_TS") if helperPath == nil or helperPath == "" then helperPath = "micro-treesitter" @@ -260,24 +310,8 @@ function init() startHelper() - if providerRegistered then - return - end - local ok, err = pcall(syntax.register, { - name = PROVIDER_NAME, - filetype = PROVIDER_FILETYPE, - filename = "\\.go$", - priority = 100, - request = request, - }) - if not ok then - disabled = true - logOnce("register failed: " .. tostring(err)) - if job ~= nil then - stopJob(spawnGeneration) - end - else - providerRegistered = true + if not providersRegistered then + registerProviders() end end @@ -294,6 +328,6 @@ function deinit() nextBufferID = 0 disabled = false logged = false - providerRegistered = false - lastHelperError = nil + providersRegistered = false + errorLogged = {} end diff --git a/treesitter/testdata/lua_harness.lua b/treesitter/testdata/lua_harness.lua index 71af39dc..f476f7b6 100644 --- a/treesitter/testdata/lua_harness.lua +++ b/treesitter/testdata/lua_harness.lua @@ -7,12 +7,12 @@ local logs = {} local sent = {} local stops = 0 -local registered +local registered = {} local onStdout, onStderr, onExit function import(name) if name == "micro/syntax" then - return { register = function(provider) registered = provider end } + return { register = function(provider) registered[#registered + 1] = provider end } elseif name == "micro/shell" then return { JobSpawn = function(command, args, out, err, exit) @@ -25,7 +25,7 @@ function import(name) elseif name == "micro" then return { Log = function(...) logs[#logs + 1] = table.concat({ ... }, " ") end } elseif name == "os" then - return { getenv = function() return nil end } + return { Getenv = function() return nil end } end error("unexpected import: " .. name) end @@ -50,18 +50,44 @@ local function joinFlat(spans) return table.concat(parts, "|") end +local function providerFor(filetype) + for _, provider in ipairs(registered) do + if provider.filetype == filetype then + return provider + end + end + return nil +end + +local function countLogs(fragment) + local n = 0 + for _, line in ipairs(logs) do + if line:find(fragment, 1, true) then n = n + 1 end + end + return n +end + init() -check(registered ~= nil, "provider was not registered") -check(registered.name == "treesitter-go", "provider name") -check(registered.filetype == "go", "provider filetype") -check(registered.filename == "\\.go$", "provider filename regex") -check(registered.priority == 100, "provider priority") +check(#registered == 14, "expected 14 providers, got " .. #registered) +local go = providerFor("go") +check(go ~= nil, "go provider was not registered") +check(go.name == "treesitter-go", "provider name") +check(go.filename == "\\.go$", "provider filename regex") +check(go.priority == 100, "provider priority") +for _, filetype in ipairs({ "go", "python", "javascript", "typescript", "rust", "c", + "c++", "shell", "json", "toml", "html", "css", "lua", "yaml" }) do + local provider = providerFor(filetype) + check(provider ~= nil, "missing provider for " .. filetype) + check(provider.name == "treesitter-" .. filetype, "wrong name for " .. filetype) + check(provider.request == go.request, "provider request function is not shared") +end +check(providerFor("c++").signature ~= nil, "c++ signature missing") -- Frame framing: exact bytes, byte length, id and generation from ctx. local delivered local function reply(spans) delivered = spans end local text = "package x\n" -registered.request({ id = 1, generation = 5, filetype = "go", text = text }, reply) +go.request({ id = 1, generation = 5, filetype = "go", text = text }, reply) check(#sent == 1, "expected one REQ frame, got " .. #sent) local wantFrame = "REQ\t1\t5\tgo\t10\n" .. text check(sent[1] == wantFrame, string.format("frame mismatch: %q", sent[1])) @@ -78,29 +104,29 @@ check(joinFlat(delivered) == "0|0|7|preproc|1|0|1|comment", -- A stale generation must not overwrite the newer pending reply. delivered = nil -registered.request({ id = 1, generation = 6, filetype = "go", text = text }, reply) +go.request({ id = 1, generation = 6, filetype = "go", text = text }, reply) feed("SPANS\t1\t5\t0\n") check(delivered == nil, "stale generation was delivered") feed("SPANS\t1\t6\t0\n") check(delivered ~= nil and #delivered == 0, "current generation clears spans") --- ERR for the current generation clears spans and logs once. +-- ERR for the current generation clears spans and logs once per filetype. delivered = nil -registered.request({ id = 1, generation = 7, filetype = "go", text = text }, reply) -feed("ERR\t1\t7\tquery failed\n") +go.request({ id = 1, generation = 7, filetype = "ruby", text = "puts 1\n" }, reply) +feed("ERR\t1\t7\tunsupported filetype \"ruby\"\n") check(delivered ~= nil and #delivered == 0, "ERR should clear spans") -local sawError = false -for _, line in ipairs(logs) do - if line:find("query failed", 1, true) then sawError = true end -end -check(sawError, "ERR was not logged") +check(countLogs("ruby unavailable") == 1, "ERR was not logged exactly once") +delivered = nil +go.request({ id = 1, generation = 8, filetype = "ruby", text = "puts 1\n" }, reply) +feed("ERR\t1\t8\tunsupported filetype \"ruby\"\n") +check(countLogs("ruby unavailable") == 1, "repeated ERR was logged again") -- Unknown frames are a protocol failure: the helper is stopped and disabled. local stopsBefore = stops -feed("WHAT\t1\t7\tnonsense\n") +feed("WHAT\t1\t8\tnonsense\n") check(stops == stopsBefore + 1, "unknown frame did not stop the helper") delivered = nil -registered.request({ id = 1, generation = 8, filetype = "go", text = text }, reply) +go.request({ id = 1, generation = 9, filetype = "go", text = text }, reply) check(delivered ~= nil and #delivered == 0, "disabled plugin must still answer") deinit()