diff --git a/docs/contracts/extension-api.md b/docs/contracts/extension-api.md index 6bb4919..48ebbb5 100644 --- a/docs/contracts/extension-api.md +++ b/docs/contracts/extension-api.md @@ -1,6 +1,6 @@ # Extension API -**Contract version:** `1.0` (compatibility target; host-side legacy parity is incomplete) +**Contract version:** `1.0` (Registry SemVer `1.0.0`; documented stable extension surface) Extensions run inside a dedicated Lua environment and receive the capabilities explicitly exposed by the Runtime. The API preserves the basic module shape used @@ -51,7 +51,7 @@ the runtime is closing. ## Capability facade -The current manager exposes limited capabilities: +The current manager exposes owner-scoped capabilities. Contributions are validated before they enter the semantic UI model: - `core.commands:register(name, handler, options)` and `remove(name)` for extension-owned commands. An extension cannot replace a command owned by @@ -68,12 +68,36 @@ The current manager exposes limited capabilities: must be flat strings, numbers or booleans, and extensions never touch a logger, `io` or the console directly. - `core.unload_plugin(name)` for explicit unload. -- `core.ui:publish(model)` is currently a narrow provisional passthrough and is - expected to be replaced by validated extension UI contributions before a - stable release. - -The full legacy `system`, `themes`, package path and UI extension API has not -been migrated. It must not be assumed available based on the old CDIN tree. +- `core.ui:register_panel(panel_id, panel)` and + `core.ui:update_panel(panel_id, panel)` add or replace one panel owned by + the calling extension. `core.ui:remove_panel(panel_id)` removes only that + extension's panel. +- `core.ui:set_prompt(prompt)` sets the Runtime-owned prompt when no other + extension owns it; `core.ui:clear_prompt()` clears only the caller's prompt. + Panel and prompt data use the accepted + [UI presentation contract v1](https://github.com/cdin-editor/spec/blob/main/docs/proposals/ui-presentation-contract-v1.md) + schema: bounded `label`, `text`, `list`, `tree`, `button` and `input` + widgets, stable IDs, typed actions and validated text/style fields. +- Panel and prompt ownership is transactional with extension lifecycle. + Contributions are removed after failed initialization, explicit unload, or + Runtime shutdown. An extension cannot overwrite or remove another owner's + panel/prompt. +- `core.ui:publish` is **not exposed to extensions by default**. A trusted + embedding application may explicitly set Runtime construction option + `allow_legacy_extension_ui_publish = true` to expose the old low-level + passthrough during migration. It bypasses the owner-scoped semantic model, + is outside the stable Extension Contract, and must never be enabled for + untrusted extension sources. Extensions should use the contribution methods + above instead. + +The full legacy `system`, package path and direct renderer API has not been +migrated. It must not be assumed available based on the old CDIN tree. +Extensions express ordinary UI through the bounded declarative schema; +free-form drawing belongs to the separately negotiated optional `canvas` +capability and is unavailable unless the embedding Frontend explicitly accepts +it. A Frontend without `ui-presentation/1` may render static fallback text but +must not activate widget actions. Runtime does not advertise Frontend +capabilities itself. ## Resolver-backed lifecycle diff --git a/lua/cdin/runtime/application.lua b/lua/cdin/runtime/application.lua index 15cb512..7a9ef5a 100644 --- a/lua/cdin/runtime/application.lua +++ b/lua/cdin/runtime/application.lua @@ -13,6 +13,7 @@ local ThemeRegistry = require("cdin.runtime.presentation.theme.registry") local SyntaxRegistry = require("cdin.runtime.presentation.syntax.registry") local Highlighter = require("cdin.runtime.presentation.syntax.highlighter") local UIModel = require("cdin.runtime.presentation.ui.model") +local UIPresentation = require("cdin.runtime.presentation.ui.presentation") local View = require("cdin.runtime.presentation.ui.view") local DefaultCommands = require("cdin.runtime.command.defaults") local Table = require("cdin.runtime.util.tablex") @@ -56,7 +57,10 @@ function Runtime.new(options) events = self.events, config = self.config, logger = self.log:child("extension"), - ui = host.ui, + ui_model = self.ui, + host_ui = host.ui, + allow_legacy_ui_publish = options.allow_legacy_extension_ui_publish == true, + publish_ui = function() self:_publish_ui() end, styles = self.styles, themes = self.themes, syntax = self.syntax, @@ -99,11 +103,11 @@ function Runtime:start() self.styles:define("syntax.string", { description = "Language string", category = "syntax" }) self.styles:define("syntax.comment", { description = "Language comment", category = "syntax" }) self.themes:register("default", { - ["text.default"] = { role = "text.default" }, - ["text.selection"] = { role = "text.selection" }, - ["syntax.keyword"] = { role = "syntax.keyword" }, - ["syntax.string"] = { role = "syntax.string" }, - ["syntax.comment"] = { role = "syntax.comment" }, + ["text.default"] = {}, + ["text.selection"] = {}, + ["syntax.keyword"] = {}, + ["syntax.string"] = {}, + ["syntax.comment"] = {}, }) self.syntax:register("plain", require("cdin.runtime.presentation.syntax.plain")) DefaultCommands.register(self) @@ -354,6 +358,7 @@ function Runtime:_publish_ui() end snapshot.theme = self.themes.active snapshot.styles = self.themes:snapshot().styles + UIPresentation.validate_snapshot(snapshot.panels, snapshot.prompt, snapshot.styles) local failures = self.events:emit("ui.model_changed", Table.copy(snapshot)) for _, failure in ipairs(failures) do diff --git a/lua/cdin/runtime/extension/manager.lua b/lua/cdin/runtime/extension/manager.lua index e1f2ab8..69e8671 100644 --- a/lua/cdin/runtime/extension/manager.lua +++ b/lua/cdin/runtime/extension/manager.lua @@ -15,7 +15,10 @@ function Manager.new(options) events = options.events, config = options.config, logger = options.logger or Log.null(), - ui = options.ui, + ui_model = options.ui_model, + host_ui = options.host_ui, + allow_legacy_ui_publish = options.allow_legacy_ui_publish == true, + publish_ui = options.publish_ui, styles = options.styles, themes = options.themes, syntax = options.syntax, @@ -31,7 +34,7 @@ function Manager:_facade(name) -- another one: the logger owns the `component` field. local logger = self.logger:child(name) - return { + local facade = { commands = { register = function(_, command_name, handler, options) options = options or {} @@ -77,8 +80,35 @@ function Manager:_facade(name) return manager:unload(plugin_name) end, ui = { - publish = function(_, model) - return manager.ui:publish(model) + register_panel = function(_, panel_id, panel) + if not manager.ui_model then + errors.raise("UNSUPPORTED", "Runtime UI contributions are unavailable") + end + return manager.ui_model:set_panel(panel_id, panel, name) + end, + update_panel = function(_, panel_id, panel) + if not manager.ui_model then + errors.raise("UNSUPPORTED", "Runtime UI contributions are unavailable") + end + return manager.ui_model:set_panel(panel_id, panel, name) + end, + remove_panel = function(_, panel_id) + if not manager.ui_model then + errors.raise("UNSUPPORTED", "Runtime UI contributions are unavailable") + end + return manager.ui_model:remove_panel(panel_id, name) + end, + set_prompt = function(_, prompt) + if not manager.ui_model then + errors.raise("UNSUPPORTED", "Runtime UI contributions are unavailable") + end + return manager.ui_model:set_prompt(prompt, name) + end, + clear_prompt = function() + if not manager.ui_model then + errors.raise("UNSUPPORTED", "Runtime UI contributions are unavailable") + end + return manager.ui_model:clear_prompt(name) end, }, styles = { @@ -107,6 +137,29 @@ function Manager:_facade(name) end, }, } + + -- Not visible to extensions by default. Only a trusted embedding + -- application may explicitly enable this migration-only escape hatch. + if manager.allow_legacy_ui_publish then + facade.ui.publish = function(_, model) + if type(manager.host_ui) ~= "table" or type(manager.host_ui.publish) ~= "function" then + errors.raise("UNSUPPORTED", "direct UI publication is unavailable") + end + return manager.host_ui:publish(model) + end + end + return facade +end + +function Manager:_publish_ui() + if type(self.publish_ui) ~= "function" then return true end + local ok, failure = pcall(self.publish_ui) + if not ok then + local normalized = errors.normalize(failure, "INTERNAL", "publish extension UI") + self.logger:error("extension UI snapshot publication failed", { code = normalized.code }) + return false, normalized + end + return true end function Manager:load(name, source, config, options) @@ -140,6 +193,8 @@ function Manager:load(name, source, config, options) if self.themes then self.themes:remove_owner(name) end if self.syntax then self.syntax:remove_owner(name) end if self.styles then self.styles:remove_owner(name) end + local ui_changed = self.ui_model and self.ui_model:remove_owner(name) + if ui_changed then self:_publish_ui() end local failure = errors.normalize(module, "BAD_EXTENSION", "load plugin:" .. name) self.logger:child(name):error("extension failed to load", { code = failure.code }) error(failure, 0) @@ -154,6 +209,7 @@ function Manager:load(name, source, config, options) } self.plugins[name] = record self.logger:child(name):info("extension loaded", { source = record.source }) + self:_publish_ui() return record end @@ -232,7 +288,9 @@ function Manager:unload(name) if self.themes then self.themes:remove_owner(name) end if self.syntax then self.syntax:remove_owner(name) end if self.styles then self.styles:remove_owner(name) end + if self.ui_model then self.ui_model:remove_owner(name) end self.plugins[name] = nil + self:_publish_ui() if not ok then local normalized = errors.normalize(failure, "BAD_EXTENSION", "plugin unload:" .. name) @@ -270,6 +328,7 @@ function Manager:shutdown() if self.themes then self.themes:remove_owner(name) end if self.syntax then self.syntax:remove_owner(name) end if self.styles then self.styles:remove_owner(name) end + if self.ui_model then self.ui_model:remove_owner(name) end end self.plugins = {} end diff --git a/lua/cdin/runtime/presentation/style/registry.lua b/lua/cdin/runtime/presentation/style/registry.lua index 52e6db4..e0ff324 100644 --- a/lua/cdin/runtime/presentation/style/registry.lua +++ b/lua/cdin/runtime/presentation/style/registry.lua @@ -1,5 +1,6 @@ local errors = require("cdin.runtime.core.error") local tablex = require("cdin.runtime.util.tablex") +local Presentation = require("cdin.runtime.presentation.ui.presentation") local Registry = {} Registry.__index = Registry @@ -13,9 +14,7 @@ end function Registry:define(name, options) options = options or {} - if type(name) ~= "string" or name == "" then - errors.raise("INVALID_ARGUMENT", "style role name is required") - end + Presentation.validate_style_role(name) if self.roles[name] then errors.raise("DUPLICATE_REGISTRATION", "style role already exists: " .. name) end diff --git a/lua/cdin/runtime/presentation/theme/registry.lua b/lua/cdin/runtime/presentation/theme/registry.lua index 50fef0e..2262523 100644 --- a/lua/cdin/runtime/presentation/theme/registry.lua +++ b/lua/cdin/runtime/presentation/theme/registry.lua @@ -1,5 +1,6 @@ local errors = require("cdin.runtime.core.error") local tablex = require("cdin.runtime.util.tablex") +local Presentation = require("cdin.runtime.presentation.ui.presentation") local Registry = {} Registry.__index = Registry @@ -23,16 +24,13 @@ function Registry:register(name, theme, options) local copied = tablex.copy(theme) for role_name, style in pairs(copied) do + Presentation.validate_style_role(role_name) if not self.styles:has(role_name) then errors.raise("INVALID_ARGUMENT", "theme references unknown style role", { role = role_name, }) end - if type(style) ~= "table" then - errors.raise("INVALID_ARGUMENT", "theme style role must map to a table", { - role = role_name, - }) - end + Presentation.validate_style(style, "theme." .. role_name) end self.themes[name] = { diff --git a/lua/cdin/runtime/presentation/ui/model.lua b/lua/cdin/runtime/presentation/ui/model.lua index f085b78..7bb7c1e 100644 --- a/lua/cdin/runtime/presentation/ui/model.lua +++ b/lua/cdin/runtime/presentation/ui/model.lua @@ -1,6 +1,7 @@ local errors = require("cdin.runtime.core.error") local tablex = require("cdin.runtime.util.tablex") local PaneTree = require("cdin.runtime.presentation.ui.pane_tree") +local Presentation = require("cdin.runtime.presentation.ui.presentation") -- Model the editor state shared with a frontend. This is semantic state only: -- it contains no pixel positions, rendering objects or document text. @@ -16,6 +17,8 @@ function Model.new() revision = 0, views = {}, panels = {}, + panel_owners = {}, + prompt_owner = nil, focus = nil, prompt = nil, status = {}, @@ -143,24 +146,101 @@ function Model:set_status(key, value) self:_touch() end -function Model:set_prompt(prompt) - self.prompt = prompt and tablex.copy(prompt) or nil +function Model:set_prompt(prompt, owner) + owner = owner or "core" + if prompt == nil then + return self:clear_prompt(owner) + end + if self.prompt ~= nil and self.prompt_owner ~= owner then + errors.raise("PERMISSION_DENIED", "prompt is owned by another extension", { + owner = self.prompt_owner, requester = owner, + }) + end + Presentation.validate_prompt(prompt) + self.prompt = tablex.copy(prompt) + self.prompt_owner = owner self:_touch() + return true end -function Model:set_panel(name, panel) +function Model:clear_prompt(owner) + owner = owner or "core" + if self.prompt == nil then return false end + if self.prompt_owner ~= owner then + errors.raise("PERMISSION_DENIED", "extension cannot clear another owner's prompt", { + owner = self.prompt_owner, requester = owner, + }) + end + self.prompt = nil + self.prompt_owner = nil + self:_touch() + return true +end + +function Model:set_panel(name, panel, owner) + owner = owner or "core" if type(name) ~= "string" or name == "" then errors.raise("INVALID_ARGUMENT", "panel name is required") end + local current_owner = self.panel_owners[name] + if current_owner ~= nil and current_owner ~= owner then + errors.raise("PERMISSION_DENIED", "extension cannot replace another owner's panel", { + panel_id = name, owner = current_owner, requester = owner, + }) + end if panel ~= nil and self.panels[name] == nil then local panel_count = 0 for _ in pairs(self.panels) do panel_count = panel_count + 1 end if panel_count >= Model.MAX_PANELS then errors.raise("LIMIT_EXCEEDED", "UI model panel limit reached", { limit = Model.MAX_PANELS }) end + Presentation.validate_panel(name, panel) + self.panels[name] = tablex.copy(panel) + self.panel_owners[name] = owner + self:_touch() + return true end - self.panels[name] = panel and tablex.copy(panel) or nil + if panel == nil then + return self:remove_panel(name, owner) + end + Presentation.validate_panel(name, panel) + self.panels[name] = tablex.copy(panel) + self.panel_owners[name] = owner + self:_touch() + return true +end + +function Model:remove_panel(name, owner) + owner = owner or "core" + if self.panels[name] == nil then return false end + local current_owner = self.panel_owners[name] + if current_owner ~= owner then + errors.raise("PERMISSION_DENIED", "extension cannot remove another owner's panel", { + panel_id = name, owner = current_owner, requester = owner, + }) + end + self.panels[name] = nil + self.panel_owners[name] = nil self:_touch() + return true +end + +function Model:remove_owner(owner) + local changed = false + for panel_id, panel_owner in pairs(self.panel_owners) do + if panel_owner == owner then + self.panel_owners[panel_id] = nil + self.panels[panel_id] = nil + changed = true + end + end + if self.prompt ~= nil and self.prompt_owner == owner then + self.prompt = nil + self.prompt_owner = nil + changed = true + end + if changed then self:_touch() end + return changed end function Model:set_theme(name) @@ -173,6 +253,7 @@ function Model:_touch() end function Model:snapshot() + Presentation.validate_snapshot(self.panels, self.prompt, {}) local views = {} for view_id, view in pairs(self.views) do views[view_id] = view:snapshot() diff --git a/lua/cdin/runtime/presentation/ui/presentation.lua b/lua/cdin/runtime/presentation/ui/presentation.lua new file mode 100644 index 0000000..d622a0f --- /dev/null +++ b/lua/cdin/runtime/presentation/ui/presentation.lua @@ -0,0 +1,238 @@ +local errors = require("cdin.runtime.core.error") + +local P = { + MAX_STYLE_BYTES = 32768, MAX_STYLE_ROLES = 256, MAX_PANELS = 16, + MAX_WIDGETS_PER_PANEL = 128, MAX_WIDGET_DEPTH = 16, MAX_TOTAL_WIDGETS = 1024, + MAX_TEXT_BYTES = 16384, MAX_PRESENTATION_BYTES = 131072, + DEFAULT_INPUT_BYTES = 4096, MAX_INPUT_BYTES = 16384, +} + +local attributes = { + foreground = "rgba", background = "rgba", bold = "bool", italic = "bool", + underline = "bool", strikethrough = "bool", dim = "bool", inverse = "bool", +} + +local function invalid(field, message, details) + details = details or {}; details.field = field + errors.raise("INVALID_ARGUMENT", message, details) +end + +local function text(value, field, limit, empty) + if type(value) ~= "string" or (not empty and value == "") then + invalid(field, field .. " must be " .. (empty and "text" or "non-empty text")) + end + if #value > limit then invalid(field, field .. " exceeds its byte limit", { limit = limit, actual = #value }) end + local _, position = utf8.len(value) + if position then invalid(field, field .. " must contain valid UTF-8", { byte = position }) end + return true +end + +local function bool(value, field) + if value ~= nil and type(value) ~= "boolean" then invalid(field, field .. " must be boolean") end +end + +local function integer(value, field, minimum, maximum) + if type(value) ~= "number" or value % 1 ~= 0 or value < minimum or value > maximum then + invalid(field, field .. " is outside its integer range", { minimum = minimum, maximum = maximum }) + end +end + +local function array(value, field) + if type(value) ~= "table" or getmetatable(value) ~= nil then invalid(field, field .. " must be an array") end + local count, maximum = 0, 0 + for key in pairs(value) do + if type(key) ~= "number" or key < 1 or key % 1 ~= 0 then invalid(field, field .. " must be a dense array") end + count = count + 1; if key > maximum then maximum = key end + end + if count ~= maximum then invalid(field, field .. " must be a dense array") end + return count +end + +local function estimate(value, depth) + depth = depth or 0 + if depth > 24 then invalid("presentation", "presentation data is too deeply nested") end + local kind = type(value) + if kind == "nil" then return 1 end + if kind == "boolean" or kind == "number" then return 8 end + if kind == "string" then return #value + 8 end + if kind ~= "table" or getmetatable(value) ~= nil then invalid("presentation", "presentation data must be serializable") end + local total = 8 + for key, item in pairs(value) do + if type(key) ~= "string" and (type(key) ~= "number" or key < 1 or key % 1 ~= 0) then + invalid("presentation", "presentation map keys must be text") + end + if type(key) == "string" then total = total + #key end + total = total + estimate(item, depth + 1) + if total > P.MAX_PRESENTATION_BYTES then errors.raise("LIMIT_EXCEEDED", "presentation snapshot exceeds its byte limit", { limit = P.MAX_PRESENTATION_BYTES }) end + end + return total +end + +function P.validate_style_role(name) + text(name, "style role", 64, false) + if not name:match("^[A-Za-z0-9][A-Za-z0-9._%-]*$") then invalid("style role", "invalid style-role identifier") end +end + +local function rgba(value, field) + if type(value) ~= "table" or getmetatable(value) ~= nil then invalid(field, field .. " must be an RGBA map") end + for _, channel in ipairs({ "r", "g", "b", "a" }) do integer(value[channel], field .. "." .. channel, 0, 255) end + for key in pairs(value) do + if key ~= "r" and key ~= "g" and key ~= "b" and key ~= "a" then invalid(field, "unknown RGBA channel", { channel = tostring(key) }) end + end +end + +function P.validate_style(style, field) + field = field or "style" + if type(style) ~= "table" or getmetatable(style) ~= nil then invalid(field, "style must be a map") end + for key, value in pairs(style) do + local expected = attributes[key] + if not expected then invalid(field, "unsupported style attribute", { attribute = tostring(key) }) end + if expected == "rgba" then rgba(value, field .. "." .. key) else bool(value, field .. "." .. key) end + end +end + +function P.validate_style_table(styles) + if type(styles) ~= "table" or getmetatable(styles) ~= nil then invalid("styles", "style table must be a map") end + local count = 0 + for role, style in pairs(styles) do + count = count + 1; P.validate_style_role(role); P.validate_style(style, "styles." .. role) + end + if count > P.MAX_STYLE_ROLES then errors.raise("LIMIT_EXCEEDED", "style-role count exceeds its limit", { limit = P.MAX_STYLE_ROLES }) end + if estimate(styles) > P.MAX_STYLE_BYTES then errors.raise("LIMIT_EXCEEDED", "style table exceeds its byte limit", { limit = P.MAX_STYLE_BYTES }) end +end + +local function action(value, field, reserve_value) + if type(value) ~= "table" or getmetatable(value) ~= nil then invalid(field, "action must be a map") end + text(value.command, field .. ".command", 128, false) + if value.arguments ~= nil then + if type(value.arguments) ~= "table" or getmetatable(value.arguments) ~= nil then invalid(field .. ".arguments", "arguments must be a map") end + for key in pairs(value.arguments) do if type(key) ~= "string" then invalid(field .. ".arguments", "argument keys must be text") end end + if reserve_value and value.arguments.value ~= nil then invalid(field .. ".arguments.value", "input actions reserve the value argument") end + estimate(value.arguments) + end +end + +local function common(widget, field, state) + if type(widget) ~= "table" or getmetatable(widget) ~= nil then invalid(field, "widget must be a map") end + text(widget.id, field .. ".id", 128, false); text(widget.kind, field .. ".kind", 64, false) + bool(widget.enabled, field .. ".enabled"); bool(widget.visible, field .. ".visible") + if widget.accessible_label ~= nil then text(widget.accessible_label, field .. ".accessible_label", P.MAX_TEXT_BYTES, true) end + if state.ids[widget.id] then invalid(field .. ".id", "duplicate widget ID", { id = widget.id }) end + state.ids[widget.id] = true; state.total = state.total + 1 +end + +local function tree_node(node, field, depth, state, seen) + if depth > P.MAX_WIDGET_DEPTH then errors.raise("LIMIT_EXCEEDED", "tree widget nesting exceeds its limit", { limit = P.MAX_WIDGET_DEPTH }) end + if type(node) ~= "table" or getmetatable(node) ~= nil then invalid(field, "tree node must be a map") end + text(node.id, field .. ".id", 128, false); text(node.label, field .. ".label", P.MAX_TEXT_BYTES, true) + if seen[node.id] then invalid(field .. ".id", "duplicate tree-node ID", { id = node.id }) end + seen[node.id] = true; state.total = state.total + 1 + bool(node.expanded, field .. ".expanded"); bool(node.enabled, field .. ".enabled") + if node.activate ~= nil then action(node.activate, field .. ".activate") end + if node.children ~= nil then + local count = array(node.children, field .. ".children") + for i = 1, count do tree_node(node.children[i], field .. ".children[" .. i .. "]", depth + 1, state, seen) end + end +end + +local function widget(value, field, depth, state) + if depth > P.MAX_WIDGET_DEPTH then errors.raise("LIMIT_EXCEEDED", "widget nesting exceeds its limit", { limit = P.MAX_WIDGET_DEPTH }) end + common(value, field, state) + local kind = value.kind + if kind == "label" then + text(value.text, field .. ".text", P.MAX_TEXT_BYTES, true) + if value.role ~= nil then P.validate_style_role(value.role) end + elseif kind == "text" then + local count = array(value.runs, field .. ".runs") + for i = 1, count do + local run = value.runs[i] + if type(run) ~= "table" or getmetatable(run) ~= nil then invalid(field .. ".runs", "text run must be a map") end + text(run.text, field .. ".runs[" .. i .. "].text", P.MAX_TEXT_BYTES, true) + if run.role ~= nil then P.validate_style_role(run.role) end + end + elseif kind == "list" then + local count = array(value.items, field .. ".items"); local seen = {} + for i = 1, count do + local item, name = value.items[i], field .. ".items[" .. i .. "]" + if type(item) ~= "table" or getmetatable(item) ~= nil then invalid(name, "list item must be a map") end + text(item.id, name .. ".id", 128, false); text(item.label, name .. ".label", P.MAX_TEXT_BYTES, true) + if seen[item.id] then invalid(name .. ".id", "duplicate list item ID") end + seen[item.id] = true + if item.detail ~= nil then text(item.detail, name .. ".detail", P.MAX_TEXT_BYTES, true) end + bool(item.enabled, name .. ".enabled"); if item.activate ~= nil then action(item.activate, name .. ".activate") end + state.total = state.total + 1 + end + if value.selected_id ~= nil then text(value.selected_id, field .. ".selected_id", 128, false) end + elseif kind == "tree" then + local count = array(value.nodes, field .. ".nodes"); local seen = {} + for i = 1, count do tree_node(value.nodes[i], field .. ".nodes[" .. i .. "]", 1, state, seen) end + elseif kind == "button" then + text(value.label, field .. ".label", P.MAX_TEXT_BYTES, true); action(value.activate, field .. ".activate") + elseif kind == "input" then + text(value.value, field .. ".value", P.MAX_TEXT_BYTES, true) + if value.placeholder ~= nil then text(value.placeholder, field .. ".placeholder", P.MAX_TEXT_BYTES, true) end + local limit = value.max_bytes == nil and P.DEFAULT_INPUT_BYTES or value.max_bytes + integer(limit, field .. ".max_bytes", 1, P.MAX_INPUT_BYTES) + if #value.value > limit then invalid(field .. ".value", "input value exceeds max_bytes", { limit = limit }) end + bool(value.multiline, field .. ".multiline"); action(value.submit, field .. ".submit", true) + else + -- Unknown kinds are preserved for the Frontend's static, non-interactive fallback. + if value.label ~= nil then text(value.label, field .. ".label", P.MAX_TEXT_BYTES, true) end + if value.text ~= nil then text(value.text, field .. ".text", P.MAX_TEXT_BYTES, true) end + end + if state.total > P.MAX_TOTAL_WIDGETS then errors.raise("LIMIT_EXCEEDED", "presentation widget count exceeds its limit", { limit = P.MAX_TOTAL_WIDGETS }) end +end + +local function widgets(list, field, state) + local count = array(list, field) + if count > P.MAX_WIDGETS_PER_PANEL then errors.raise("LIMIT_EXCEEDED", field .. " exceeds its widget limit", { limit = P.MAX_WIDGETS_PER_PANEL }) end + for i = 1, count do widget(list[i], field .. "[" .. i .. "]", 1, state) end +end + +function P.validate_panel(panel_id, panel, state) + text(panel_id, "panel_id", 128, false) + if type(panel) ~= "table" or getmetatable(panel) ~= nil then invalid("panel", "panel must be a map") end + if panel.id ~= nil and panel.id ~= panel_id then invalid("panel.id", "panel.id must match its outer ID") end + text(panel.kind, "panel.kind", 64, false) -- unknown kinds degrade to content in compatible Frontends + text(panel.title, "panel.title", P.MAX_TEXT_BYTES, true) + bool(panel.visible, "panel.visible"); bool(panel.focusable, "panel.focusable"); bool(panel.scrollable, "panel.scrollable") + if panel.selected_widget_id ~= nil then text(panel.selected_widget_id, "panel.selected_widget_id", 128, false) end + state = state or { total = 0, ids = {} } + widgets(panel.widgets, "panel.widgets", state) + estimate(panel) + return state +end + +function P.validate_prompt(prompt, state) + if prompt == nil then return state end + if type(prompt) ~= "table" or getmetatable(prompt) ~= nil then invalid("prompt", "prompt must be a map") end + text(prompt.id, "prompt.id", 128, false); text(prompt.title, "prompt.title", P.MAX_TEXT_BYTES, true) + if prompt.message ~= nil then text(prompt.message, "prompt.message", P.MAX_TEXT_BYTES, true) end + bool(prompt.visible, "prompt.visible"); if prompt.cancel ~= nil then action(prompt.cancel, "prompt.cancel") end + state = state or { total = 0, ids = {} }; widgets(prompt.widgets, "prompt.widgets", state) + estimate(prompt) + return state +end + +function P.validate_snapshot(panels, prompt, styles) + local count, total_widgets, total_bytes = 0, 0, 0 + for id, panel in pairs(panels or {}) do + count = count + 1 + if count > P.MAX_PANELS then errors.raise("LIMIT_EXCEEDED", "panel count exceeds its limit", { limit = P.MAX_PANELS }) end + local state = P.validate_panel(id, panel, { total = total_widgets, ids = {} }) + total_widgets = state.total + total_bytes = total_bytes + estimate(panel) + end + local prompt_state = P.validate_prompt(prompt, { total = total_widgets, ids = {} }) + total_widgets = prompt_state and prompt_state.total or total_widgets + P.validate_style_table(styles or {}) + total_bytes = total_bytes + estimate(prompt) + estimate(styles or {}) + if total_widgets > P.MAX_TOTAL_WIDGETS then + errors.raise("LIMIT_EXCEEDED", "presentation widget count exceeds its limit", { limit = P.MAX_TOTAL_WIDGETS }) + end + if total_bytes > P.MAX_PRESENTATION_BYTES then + errors.raise("LIMIT_EXCEEDED", "presentation exceeds its aggregate byte limit", { limit = P.MAX_PRESENTATION_BYTES }) + end +end + +return P diff --git a/tests/integration/run.lua b/tests/integration/run.lua index 9f5ec3f..86aabc5 100644 --- a/tests/integration/run.lua +++ b/tests/integration/run.lua @@ -363,18 +363,90 @@ A.falsy(runtime.commands:get("plugin.hello")) -- RT-013 | Reference: https://github.com/cdin-editor/spec -- Extension-owned presentation contributions must be removed on unload. local owned_plugin = [[return { init = function(core) + core.commands:register("plugin.panel.activate", function() return "activated" end) + core.commands:register("plugin.panel.legacy-publish-hidden", function() return core.ui.publish == nil end) core.styles:define("plugin.sample", { category = "test" }) - core.themes:register("plugin.sample", { ["plugin.sample"] = { role = "plugin.sample" } }) + core.themes:register("plugin.sample", { + ["plugin.sample"] = { + foreground = { r = 180, g = 190, b = 200, a = 255 }, + bold = true, + }, + }) core.syntax:register("plugin-sample", { extensions = { "psamp" }, tokenize = function() return {} end }) + core.ui:register_panel("plugin.sample.panel", { + kind = "content", + title = "Plugin sample", + widgets = { + { id = "hello", kind = "label", text = "Hello" }, + { id = "activate", kind = "button", label = "Activate", + activate = { command = "plugin.panel.activate" } }, + { id = "search", kind = "input", value = "", max_bytes = 128, + submit = { command = "plugin.panel.activate", arguments = {} } }, + }, + }) + core.ui:set_prompt({ + id = "plugin.sample.prompt", + title = "Plugin prompt", + widgets = { + { id = "prompt-label", kind = "text", runs = { { text = "Enter a value" } } }, + }, + }) end }]] runtime.plugins:load("presentation-sample", owned_plugin, {}) A.truthy(runtime.styles:has("plugin.sample")) A.truthy(runtime.themes.themes["plugin.sample"] ~= nil) A.truthy(runtime.syntax:get("plugin-sample") ~= nil) -runtime.plugins:unload("presentation-sample") +A.truthy(runtime.ui.panels["plugin.sample.panel"] ~= nil) +local hidden_ok, publish_is_hidden = runtime.commands:execute("plugin.panel.legacy-publish-hidden", {}, {}) +A.truthy(hidden_ok) +A.truthy(publish_is_hidden) +A.equal(runtime.ui.prompt.id, "plugin.sample.prompt") +A.equal(host.published[#host.published].panels["plugin.sample.panel"].widgets[2].activate.command, + "plugin.panel.activate") +local panel_conflict = [[return { init = function(core) + core.ui:register_panel("plugin.sample.panel", { + kind = "content", title = "Takeover", widgets = {}, + }) +end }]] +local conflict_error = A.error_code(function() + runtime.plugins:load("panel-takeover", panel_conflict, {}) +end, "PERMISSION_DENIED") +A.equal(conflict_error.details.panel_id, "plugin.sample.panel") +A.truthy(runtime.plugins:unload("presentation-sample")) A.falsy(runtime.styles:has("plugin.sample")) A.falsy(runtime.themes.themes["plugin.sample"] ~= nil) A.falsy(runtime.syntax:get("plugin-sample") ~= nil) +A.falsy(runtime.ui.panels["plugin.sample.panel"]) +A.equal(runtime.ui.prompt, nil) +A.falsy(runtime.commands:get("plugin.panel.activate")) +A.falsy(runtime.commands:get("plugin.panel.legacy-publish-hidden")) + +local invalid_presentation_plugin = [[return { init = function(core) + core.ui:register_panel("plugin.invalid.panel", { + kind = "content", title = "Invalid", + widgets = { + { id = "duplicate", kind = "label", text = "one" }, + { id = "duplicate", kind = "button", label = "two", + activate = { command = "plugin.panel.activate" } }, + }, + }) +end }]] +A.error_code(function() + runtime.plugins:load("invalid-presentation", invalid_presentation_plugin, {}) +end, "INVALID_ARGUMENT") +A.falsy(runtime.ui.panels["plugin.invalid.panel"]) + +local invalid_style_plugin = [[return { init = function(core) + core.styles:define("plugin.invalid-style", { category = "test" }) + core.themes:register("plugin.invalid-style", { + ["plugin.invalid-style"] = { foreground = { r = 256, g = 0, b = 0, a = 255 } }, + }) +end }]] +A.error_code(function() + runtime.plugins:load("invalid-style", invalid_style_plugin, {}) +end, "INVALID_ARGUMENT") +A.falsy(runtime.styles:has("plugin.invalid-style")) + local command_takeover = [[return { init = function(core) core.commands:register("document.undo", function() return "hijacked" end, { overwrite = true }) end }]] @@ -386,10 +458,14 @@ A.truthy(runtime.commands:get("document.undo")) A.equal(runtime.commands:get("document.undo").owner, "core") local failed_plugin = [[return { init = function(core) core.commands:register("plugin.partial", function() return true end) + core.ui:register_panel("plugin.partial.panel", { + kind = "content", title = "Partial", widgets = {}, + }) error("intentional init failure") end, unload = function() error("must not run after init failure") end }]] A.error_code(function() runtime.plugins:load("broken", failed_plugin, {}) end, "BAD_EXTENSION") A.falsy(runtime.commands:get("plugin.partial")) +A.falsy(runtime.ui.panels["plugin.partial.panel"]) -- Resolver-backed extension lifecycle is available only to trusted Runtime -- embedders. It is deliberately not registered as a Frontend Protocol command. diff --git a/tests/unit/run.lua b/tests/unit/run.lua index 2671775..f6e3d1d 100644 --- a/tests/unit/run.lua +++ b/tests/unit/run.lua @@ -187,9 +187,19 @@ A.equal(ratio_model.revision, prior_ratio_revision + 1) A.equal(ratio_model:snapshot().layout.root.ratio, 0.65) local styles = Styles.new(); styles:define("text.default") -local themes = Themes.new(styles); themes:register("default", { ["text.default"] = { foreground = "#ffffff" } }) +local themes = Themes.new(styles); themes:register("default", { + ["text.default"] = { + foreground = { r = 255, g = 255, b = 255, a = 255 }, + bold = false, + }, +}) A.equal(themes:activate("default").name, "default") A.error_code(function() themes:register("bad", { missing = {} }) end, "INVALID_ARGUMENT") +A.error_code(function() + themes:register("invalid-color", { + ["text.default"] = { foreground = { r = 256, g = 0, b = 0, a = 255 } }, + }) +end, "INVALID_ARGUMENT") local syntax = Syntax.new(); syntax:register("toy", { extensions = { "toy" }, tokenize = function(text) return { { start = 0, finish = #text, role = "syntax.keyword" } } end }) A.error_code(function() syntax:register("other-toy", { extensions = { "toy" }, tokenize = function() return {} end })