pmacs/tests/fixtures/pmacs-mcp-resources/view.lua

249 lines
8.5 KiB
Lua

-- pmacs-mcp-resources/view.lua --- T M9.5 rendering by mimeType.
--
-- Three render modes routed by the response shape:
--
-- 1. text (mimeType: text/* or application/json — JSON pretty-
-- printed; raw text otherwise) → single buffer with the
-- resource's text content.
-- 2. directory (mimeType: application/vnd.pmacs.mcp.directory+json;
-- text content is a JSON array of child URI strings) →
-- navigable buffer with one child URI per line.
-- 3. raw (anything else, including unknown mimeTypes) → text
-- fallback. Binary blobs are rendered as a placeholder.
--
-- Returns: { kind = "text" | "directory" | "raw", body = "...",
-- mimeType = "...", children = {...} (only for directory) }
local M = {}
local function pretty_json(text)
-- v0.1: minimal pretty-printer. Walks the input adding newlines
-- after `{`, `,`, `[` and indentation; not a full JSON formatter
-- but readable for typical MCP server payloads.
-- Lua's string library doesn't ship a JSON parser; for v0.1 we
-- simply return the raw text. M9.8+ may layer a real formatter.
return text
end
-- Permissive JSON-array-of-strings extractor. Returns the strings
-- in document order, or nil if the input isn't a JSON array of
-- strings. Used by the directory and table renderers; v0.1 doesn't
-- pull in a real JSON parser.
local function json_string_array(text)
if type(text) ~= "string" then return nil end
local stripped = text:gsub("^%s*%[", ""):gsub("%]%s*$", "")
local out = {}
for entry in stripped:gmatch("\"([^\"]+)\"") do
out[#out + 1] = entry
end
if #out == 0 then return nil end
return out
end
-- Walk a single row's bracket-pair contents, picking up cells in
-- document order regardless of whether they are quoted strings or
-- bareword tokens (numbers, true/false/null, identifiers). This is
-- the v0.1 mixed-type tokenizer for table rows; Pass-3 finding 2
-- replaced an earlier two-pass approach that dropped barewords
-- whenever any quoted string was present in the row.
--
-- Cells are returned as Lua strings; numeric values become their
-- text form (e.g. `30`). The renderer pads them as text — column
-- alignment doesn't depend on cell type.
local function parse_row_cells(inner)
local cells = {}
-- inner looks like `["alice", 30]` or `[1, 2, 3]` or
-- `["a", true, "b"]`; strip the outer brackets first.
local body = inner:sub(2, -2)
local pos = 1
local len = #body
while pos <= len do
local c = body:sub(pos, pos)
if c == '"' then
-- Quoted string: scan to the next unescaped quote. v0.1
-- doesn't handle backslash escapes; the synthetic fake
-- doesn't produce them.
local close = body:find('"', pos + 1, true)
if close == nil then break end
cells[#cells + 1] = body:sub(pos + 1, close - 1)
pos = close + 1
elseif c == "," or c == " " or c == "\t" or c == "\n" then
pos = pos + 1
else
-- Bareword: number, true, false, null, identifier.
local s, e = body:find("[%-%w%.]+", pos)
if s == nil or s > pos then
-- Unrecognized character; skip it so we don't loop.
pos = pos + 1
else
cells[#cells + 1] = body:sub(s, e)
pos = e + 1
end
end
end
return cells
end
-- Parse a tiny subset of JSON: an object of the form
-- `{ "columns": [...strings...], "rows": [[...strings|numbers...], ...] }`
-- and return `{ columns = {...}, rows = {{...}, ...} }`. Returns
-- nil if the input doesn't match the expected shape.
--
-- v0.1's "JSON parser" is regex-based and intentionally permissive:
-- it works for the synthetic table the fake server produces; real
-- production callers would use a real JSON library when M9.8 hands
-- us one.
local function parse_table_payload(text)
if type(text) ~= "string" then return nil end
-- Extract the columns array.
local cols_block = text:match("\"columns\"%s*:%s*(%b[])")
if cols_block == nil then return nil end
local columns = json_string_array(cols_block)
if columns == nil then return nil end
-- Extract the rows array; each row is an inner array. `%b[]`
-- matches balanced brackets but gmatch only finds the outermost,
-- so we strip the outer pair and walk the inner ones.
local rows_block = text:match("\"rows\"%s*:%s*(%b[])")
if rows_block == nil then return nil end
local inner_block = rows_block:sub(2, -2)
local rows = {}
for inner in inner_block:gmatch("(%b[])") do
rows[#rows + 1] = parse_row_cells(inner)
end
return { columns = columns, rows = rows }
end
-- Render `{ columns, rows }` as a column-aligned text table.
-- Columns are padded to the max width of their column's values
-- (header included). Returns nil if the payload doesn't parse.
local function render_table(text)
local parsed = parse_table_payload(text)
if parsed == nil then return nil end
local cols = parsed.columns
local rows = parsed.rows
-- Compute max width per column.
local widths = {}
for i, col in ipairs(cols) do
widths[i] = #col
end
for _, row in ipairs(rows) do
for i, cell in ipairs(row) do
if widths[i] == nil or #cell > widths[i] then
widths[i] = #cell
end
end
end
-- Pad each cell to its column's width.
local function pad(s, w)
if #s >= w then return s end
return s .. string.rep(" ", w - #s)
end
local lines = {}
-- Header row.
local header_cells = {}
for i, col in ipairs(cols) do
header_cells[i] = pad(col, widths[i])
end
lines[#lines + 1] = table.concat(header_cells, " | ")
-- Separator: dashes per column, joined by " + ".
local sep_cells = {}
for i = 1, #cols do
sep_cells[i] = string.rep("-", widths[i])
end
lines[#lines + 1] = table.concat(sep_cells, " + ")
-- Data rows.
for _, row in ipairs(rows) do
local cells = {}
for i = 1, #cols do
cells[i] = pad(row[i] or "", widths[i])
end
lines[#lines + 1] = table.concat(cells, " | ")
end
return {
body = table.concat(lines, "\n") .. "\n",
columns = cols,
rows = rows,
}
end
function M.render(content_response)
-- content_response is the `result` table from `resources/read`:
-- { contents = [{ uri, mimeType, text }, ...] }
-- The MCP spec allows multiple content entries for a single read;
-- v0.1 renders the first entry and surfaces extras as a footer
-- comment line.
local contents = content_response and content_response.contents
if type(contents) ~= "table" or #contents == 0 then
return {
kind = "raw",
body = "[empty resources/read response]\n",
mimeType = "text/plain",
}
end
local primary = contents[1]
local mime = primary.mimeType or "text/plain"
local text = primary.text or ""
if mime == "application/vnd.pmacs.mcp.directory+json" then
-- Parse the JSON array of child URIs.
local children = json_string_array(text) or {}
return {
kind = "directory",
body = table.concat(children, "\n") .. "\n",
mimeType = mime,
children = children,
}
end
if mime == "application/vnd.pmacs.mcp.table+json" then
-- Pass-2 finding 1: the spec calls out query-result-shaped
-- resources rendering as table buffers. v0.1 supports a
-- minimal `{ "columns": [...], "rows": [[...]] }` shape on a
-- pmacs-specific MIME; servers that already render their
-- query results in this shape get column-aligned tables for
-- free, while servers using application/json pass through to
-- the text fallback below. Generic JSON-to-table inference is
-- still M9.8's call.
--
-- The body is rendered as:
-- <col1> | <col2> | ...
-- ------ + ------ + ...
-- <r1c1> | <r1c2> | ...
-- ...
local rendered = render_table(text)
if rendered then
return {
kind = "table",
body = rendered.body,
mimeType = mime,
columns = rendered.columns,
rows = rendered.rows,
}
end
-- If the table content didn't parse, fall through to text
-- so the user at least sees the raw payload.
end
if mime == "application/json" then
return {
kind = "text",
body = pretty_json(text) .. "\n",
mimeType = mime,
}
end
-- Default: text rendering for any text/* mimeType, or raw for
-- everything else. Either way, we put the text in the buffer.
local kind = (mime:sub(1, 5) == "text/") and "text" or "raw"
-- Ensure the body ends with a newline so cursor positioning at
-- end-of-buffer doesn't behave oddly.
if text:sub(-1) ~= "\n" then text = text .. "\n" end
return {
kind = kind,
body = text,
mimeType = mime,
}
end
return M