From dd54eef92da3edea9f8a429283a2df0cd5f039fb Mon Sep 17 00:00:00 2001 From: Willow Sparks Date: Mon, 9 Nov 2026 18:26:49 +0000 Subject: [PATCH] feat(treesitter-plugin): register one provider per filetype Build the provider table for every linked filetype, all sharing one helper process and request function. Unsupported filetypes log once and clear spans on ERR. Update the mocked Lua harness for the provider table and per-filetype error logging. --- runtime/plugins/treesitter/repo.json | 2 +- runtime/plugins/treesitter/treesitter.lua | 104 ++++++++++++++-------- treesitter/testdata/lua_harness.lua | 66 +++++++++----- 3 files changed, 116 insertions(+), 56 deletions(-) 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() -- knotmirror 0.0.1