Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 32 additions & 8 deletions docs/contracts/extension-api.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down
17 changes: 11 additions & 6 deletions lua/cdin/runtime/application.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
67 changes: 63 additions & 4 deletions lua/cdin/runtime/extension/manager.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {}
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
5 changes: 2 additions & 3 deletions lua/cdin/runtime/presentation/style/registry.lua
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down
8 changes: 3 additions & 5 deletions lua/cdin/runtime/presentation/theme/registry.lua
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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] = {
Expand Down
Loading
Loading