The PDS for SilverBullet provides a structured approach to managing dependencies between plugins and controlling their load order. The architecture is based on a central module that coordinates the entire process through SilverBullet's event system.
The central PDS library manages the plugin registration process, dependency resolution, and initialization in the proper order. This prevents issues with random loading order that could occur with the standard SilverBullet script loading mechanism.
The system utilizes SilverBullet's event mechanism for plugin discovery and communication. The main process flows as follows:
- The system core emits a
pds:discoverPlugins
event - Plugins respond with their manifests and initialization functions
- The core analyzes dependencies and determines the initialization order
- The core calls the plugin initialization functions in the correct order
Each plugin must register with the system by responding to the pds:discoverPlugins
event. The response should include plugin id, manifest and initialiation function:
event.listen {
name = "pds:discoverPlugins",
run = function()
return {
id = "my-plugin", -- Unique plugin identifier
manifest = {
name = "My Plugin", -- Display name
version = "1.0.0", -- Version in SemVer format
dependencies = { -- Required dependencies
{ id = "core", minVersion = "1.0.0" },
{ id = "utils", minVersion = "0.5.0", optional = true }
}
},
initialize = function(dependencies)
-- Plugin initialization code
-- `dependencies` contains initialized dependencies
-- Returns initialization status and optional exports
return {
success = true,
exports = {
-- Functions/objects exported for other plugins
someFunction = function() ... end,
someData = { ... }
}
}
end
}
end
}
The manifest contains plugin metadata and its dependencies:
Field | Type | Description |
---|---|---|
name |
string | Plugin display name |
version |
string | Plugin version in SemVer format |
description |
string | Optional plugin description |
author |
string | Optional author information |
dependencies |
array | List of plugin dependencies |
Each dependency is defined as an object with the following fields:
Field | Type | Description |
---|---|---|
id |
string | Required plugin identifier |
minVersion |
string | Minimum required version (optional) |
maxVersion |
string | Maximum supported version (optional) |
optional |
boolean | Whether the dependency is optional (default: false) |
The initialize
function is called by the PDS system after resolving all dependencies. It receives a dependencies
object containing all required plugins (by their identifiers).
initialize = function(dependencies)
-- `dependencies` is a map of id -> exports
local utils = dependencies["utils"]
if utils then -- Check optional dependency
utils.doSomething()
end
-- Prepare and return exported functionality
return {
success = true,
exports = {
-- API exported for other plugins
processData = function(data) ... end
}
}
end
The initialization function should return an object with information about the initialization status and optional exports:
Field | Type | Description |
---|---|---|
success |
boolean | Whether initialization succeeded |
exports |
table | Object containing exported functions/data |
error |
string | Optional error information (when success = false) |
- The PDS core starts after SilverBullet loads (event
system:ready
) - The core emits the
pds:discoverPlugins
event - Collects responses (manifests) from all plugins
- Builds a dependency graph and sorts it topologically
- Verifies that all dependencies are available and version compatible
- Initializes plugins in the correct order
- Passes exports between dependent plugins
- Handles initialization errors and missing dependencies
-- Base plugin with no dependencies
event.listen {
name = "pds:discoverPlugins",
run = function()
return {
id = "math-utils",
manifest = {
name = "Math Utilities",
version = "1.0.0"
},
initialize = function()
-- Implementation of math functions
local function add(a, b) return a + b end
local function multiply(a, b) return a * b end
return {
success = true,
exports = {
add = add,
multiply = multiply
}
}
end
}
end
}
-- Plugin using another plugin
event.listen {
name = "pds:discoverPlugins",
run = function()
return {
id = "calculator",
manifest = {
name = "Calculator",
version = "1.0.0",
dependencies = {
{ id = "math-utils", minVersion = "1.0.0" }
}
},
initialize = function(dependencies)
local mathUtils = dependencies["math-utils"]
local function calculate(expression)
-- Uses functions from math-utils
-- ...
return result
end
return {
success = true,
exports = {
calculate = calculate
}
}
end
}
end
}
The system detects cycles in the dependency graph and reports an error. Plugins should be redesigned to avoid circular dependencies.
When a required dependency is not available, the system notifies about this issue. You can mark a dependency as optional if the plugin can function without it.
The system checks version compatibility according to SemVer rules. Conflicts are reported during initialization.
- Minimal Dependencies - Design plugins with the minimum necessary dependencies
- Optional Dependencies - Mark dependencies as optional when possible
- Proper Versioning - Follow SemVer rules when updating plugins
- Well-defined API - Export only necessary functionality
- Error Handling - Always check for the availability of optional dependencies
Here are examples of how to use the PDS plugin registration system:
-- Example 1: Basic plugin with no dependencies
event.listen {
name = "pds:discoverPlugins",
run = function()
return {
id = "my-utils",
manifest = {
name = "My Utilities",
version = "1.0.0",
description = "Common utility functions",
author = "John Doe"
},
initialize = function()
-- Implementation
local function formatDate(date)
-- Date formatting logic
return "formatted date"
end
return {
success = true,
exports = {
formatDate = formatDate
}
}
end
end
}
-- Example 2: Plugin with dependencies
event.listen {
name = "pds:discoverPlugins",
run = function()
return {
id = "advanced-tools",
manifest = {
name = "Advanced Tools",
version = "0.5.0",
dependencies = {
{ id = "my-utils", minVersion = "1.0.0" },
{ id = "optional-feature", optional = true }
}
},
initialize = function(dependencies)
local utils = dependencies["my-utils"]
local optFeature = dependencies["optional-feature"]
local function process(data)
-- Use utils
local date = utils.formatDate(data.timestamp)
-- Use optional dependency if available
if optFeature then
data = optFeature.preprocess(data)
end
return data
end
return {
success = true,
exports = {
process = process
}
}
end
end
}
-- SemVer module - handling semantic versioning
local semver = {
-- SemVer module version
_VERSION = "1.0.0"
}
-- Basic semver parsing with improved handling
function semver.parse(version)
if type(version) ~= "string" then return nil end
-- Handling prerelease and build metadata
local versionPart = version:match("^([^-+]+)")
if not versionPart then return nil end
local major, minor, patch = versionPart:match("^(%d+)%.(%d+)%.(%d+)$")
if not major then
major, minor = versionPart:match("^(%d+)%.(%d+)$")
if not major then
major = versionPart:match("^(%d+)$")
if not major then return nil end
minor, patch = "0", "0"
else
patch = "0"
end
end
return {
major = tonumber(major),
minor = tonumber(minor),
patch = tonumber(patch)
}
end
-- Compare versions with better error handling
function semver.compare(v1, v2)
local ver1 = semver.parse(v1)
local ver2 = semver.parse(v2)
if not ver1 or not ver2 then return nil end
if ver1.major ~= ver2.major then
return ver1.major < ver2.major and -1 or 1
end
if ver1.minor ~= ver2.minor then
return ver1.minor < ver2.minor and -1 or 1
end
if ver1.patch ~= ver2.patch then
return ver1.patch < ver2.patch and -1 or 1
end
return 0
end
-- Check if version is in range with more descriptive code
function semver.inRange(version, minVersion, maxVersion)
-- If there are no constraints, the version is always compatible
if not minVersion and not maxVersion then return true end
-- Check lower constraint
if minVersion and semver.compare(version, minVersion) < 0 then
return false
end
-- Check upper constraint
if maxVersion and semver.compare(version, maxVersion) > 0 then
return false
end
return true
end
-- PDS Core module
PDSCore = PDSCore or {
-- Main data
plugins = {},
initialized = {},
exports = {},
debug = true,
errors = {},
-- Configuration
config = {
problemsPageName = "_problems",
logLevel = "info", -- Logging level: debug, info, warn, error
autoNavigateToProblems = true -- Whether to automatically redirect to problems page
}
}
function PDSCore:log(level, ...)
local levels = {
debug = 1,
info = 2,
warn = 3,
error = 4
}
local currentLevel = levels[self.config.logLevel] or levels.info
if levels[level] >= currentLevel then
local prefix = "[PDS:" .. level:upper() .. "]"
local args = {...}
local message = ""
for i, arg in ipairs(args) do
message = message .. tostring(arg)
if i < #args then message = message .. " " end
end
print(prefix, message)
end
end
--- Handles and logs an error that occurred during plugin operation.
--- @param pluginId string The ID of the plugin that encountered the error
--- @param errorType string The category of error (e.g., "not_found", "missing_dependency", "runtime_error")
--- @param errorMessage string Human-readable error message explaining what went wrong
--- @param details table|nil Optional table containing additional error details/context
--- @return table The created error object containing all error information
function PDSCore:handleError(pluginId, errorType, errorMessage, details)
local errorObj = {
pluginId = pluginId,
pluginName = self.plugins[pluginId] and self.plugins[pluginId].manifest.name or pluginId,
type = errorType,
message = errorMessage,
details = details,
timestamp = os.time()
}
table.insert(self.errors, errorObj)
self:log("error", pluginId, errorType, errorMessage)
return errorObj
end
-- Checking for cycles in dependency graph with cycle reporting
function PDSCore:findCycles(graph)
local visited = {}
local recStack = {}
local cycleNodes = {}
local function dfs(node, path)
if not visited[node] then
visited[node] = true
recStack[node] = true
path = path or {}
table.insert(path, node)
for _, neighbor in ipairs(graph[node] or {}) do
if not visited[neighbor] then
local result, cycle = dfs(neighbor, {unpack(path)})
if not result then
return false, cycle
end
elseif recStack[neighbor] then
-- Cycle detected - extract the cycle path
local cycleFound = {}
local startFound = false
table.insert(path, neighbor) -- Complete the cycle
for _, n in ipairs(path) do
if n == neighbor then
startFound = true
end
if startFound then
table.insert(cycleFound, n)
end
end
return false, cycleFound -- Return cycle path
end
end
end
recStack[node] = false
return true, nil
end
for node in pairs(graph) do
if not visited[node] then
local result, cycle = dfs(node)
if not result then
return true, cycle -- Cycle detected with path
end
end
end
return false, nil -- No cycles
end
--- Constructs a directed graph of plugin dependencies.
--- This function creates a dependency graph where each node is a plugin ID
--- and edges represent dependencies between plugins.
--- Only required (non-optional) dependencies are considered.
---
--- @return table A directed graph represented as an adjacency list where
--- graph[id] contains an array of plugin IDs that the plugin depends on
function PDSCore:buildDependencyGraph()
-- Use cache if data hasn't changed
if self.graphCache and not self.pluginsModified then
return self.graphCache
end
local graph = {}
for id, plugin in pairs(self.plugins) do
graph[id] = {}
for _, dep in ipairs(plugin.manifest.dependencies or {}) do
if not dep.optional then
table.insert(graph[id], dep.id)
end
end
end
-- Save in cache
self.graphCache = graph
self.pluginsModified = false
return graph
end
--- Performs a topological sort on the dependency graph to determine correct initialization order.
--- Implementation uses a depth-first search approach with cycle detection.
---
--- @param graph table The dependency graph as an adjacency list where graph[node] contains an array of dependencies
--- @return table|nil sorted A sorted array of plugin IDs in the order they should be initialized, or nil if sorting failed
--- @return table|nil cycle If a cycle is detected, returns the starting node of the cycle
function PDSCore:topologicalSort(graph)
local visited = {}
local result = {}
local cycleDetected = false
local cycleStart = nil
local function visit(node, path)
path = path or {}
if visited[node] == 2 then
return true -- Already processed
end
if visited[node] == 1 then
cycleDetected = true
cycleStart = node
return false -- Cycle detected
end
visited[node] = 1 -- Visiting
path[node] = true
for _, dep in ipairs(graph[node] or {}) do
if not visit(dep, path) then
if not cycleStart and path[dep] then
cycleStart = dep
end
return false
end
end
path[node] = nil
visited[node] = 2 -- Visited
table.insert(result, node)
return true
end
for node, _ in pairs(graph) do
if not visited[node] and not visit(node) then
-- If cycle detected, provide information about it
if cycleDetected then
local cycle = {cycleStart}
self:log("error", "Dependency cycle detected starting with", cycleStart)
return nil, cycle
end
return nil
end
end
-- Reverse order for correct sequence
local sorted = {}
for i = #result, 1, -1 do
table.insert(sorted, result[i])
end
return sorted
end
--- Checks if all dependencies of a plugin are available and version compatible.
--- This function verifies both the existence of required dependencies and their version compatibility.
--- @param plugin table The plugin object containing manifest with dependencies
--- @return table missing Array of missing required dependency IDs
--- @return table incompatible Array of incompatible dependencies with version details
function PDSCore:checkDependencies(plugin)
local missing = {}
local incompatible = {}
for _, dep in ipairs(plugin.manifest.dependencies or {}) do
if not self.plugins[dep.id] then
if not dep.optional then
table.insert(missing, dep.id)
end
elseif dep.minVersion or dep.maxVersion then
local depVersion = self.plugins[dep.id].manifest.version
-- Check version compatibility
if not semver.inRange(depVersion, dep.minVersion, dep.maxVersion) then
table.insert(incompatible, {
id = dep.id,
required = (dep.minVersion or "0.0.0") .. " - " .. (dep.maxVersion or "∞"),
actual = depVersion
})
end
end
end
return missing, incompatible
end
---Initializes a plugin and its dependencies.
---@param id string Plugin identifier to initialize
---@return boolean success Whether initialization was successful
---@return table|nil exports Exported functions and data from the plugin
---@return string|nil error Error message in case of failure
function PDSCore:initializePlugin(id)
-- If plugin already initialized, return its status and exports
if self.initialized[id] ~= nil then
return self.initialized[id], self.exports[id], self.initialized[id] == false and "Plugin already failed to initialize" or nil
end
local plugin = self.plugins[id]
if not plugin then
return false, nil, self:handleError(id, "not_found", "Plugin not found").message
end
self:log("debug", "Preparing dependencies for", id)
-- Prepare dependencies for initialization
local dependencies = {}
for _, dep in ipairs(plugin.manifest.dependencies or {}) do
if self.plugins[dep.id] then
-- Check version compatibility
if dep.minVersion or dep.maxVersion then
local depVersion = self.plugins[dep.id].manifest.version
if not semver.inRange(depVersion, dep.minVersion, dep.maxVersion) then
if not dep.optional then
local errorMsg = "Incompatible dependency version: " .. dep.id ..
" (required: " .. (dep.minVersion or "0.0.0") .. " - " .. (dep.maxVersion or "∞") ..
", actual: " .. depVersion .. ")"
return false, nil, self:handleError(id, "incompatible_version", errorMsg).message
else
self:log("warn", "Incompatible optional dependency version:", dep.id, "for plugin", id)
end
end
end
-- Initialize dependency
local success, exports, error = self:initializePlugin(dep.id)
if not success and not dep.optional then
return false, nil, self:handleError(id, "dependency_failed",
"Dependency " .. dep.id .. " failed to initialize: " .. (error or "Unknown error")).message
end
if success then
dependencies[dep.id] = exports
elseif dep.optional then
self:log("info", "Optional dependency", dep.id, "not available for", id)
end
elseif not dep.optional then
return false, nil, self:handleError(id, "missing_dependency", "Required dependency " .. dep.id .. " not found").message
end
end
-- Initialize plugin
self:log("info", "Initializing plugin:", id)
-- Use pcall for safe initialization call
local success, result = pcall(function()
return plugin.initialize(dependencies)
end)
if not success then
self.initialized[id] = false
return false, nil, self:handleError(id, "runtime_error", "Runtime error: " .. tostring(result)).message
end
if not result or type(result) ~= "table" or not result.success then
self.initialized[id] = false
local errorMsg = result and result.error or "Initialization failed with no specific error"
return false, nil, self:handleError(id, "init_failed", errorMsg).message
end
-- Successful initialization
self.initialized[id] = true
self.exports[id] = result.exports or {}
self:log("info", "Plugin", id, "initialized successfully")
return true, self.exports[id]
end
function PDSCore:initializePlugins(sortedIds)
local results = {}
self.errors = {} -- Clear previous errors
for _, id in ipairs(sortedIds) do
local success, exports, error = self:initializePlugin(id)
results[id] = {
success = success,
error = error
}
end
-- If there are errors, create problems page
if #self.errors > 0 then
self:createProblemsPage()
end
return results
end
-- Better generation of problems page content
function PDSCore:generateProblemsContent()
local content = "# Plugin Initialization Problems\n\n"
-- Summary
if #self.errors == 1 then
content = content .. "⚠️ **1 plugin failed to initialize.**\n\n"
else
content = content .. "⚠️ **" .. #self.errors .. " plugins failed to initialize.**\n\n"
end
-- Detailed error information
for _, err in ipairs(self.errors) do
content = content .. "## " .. err.pluginName .. " (`" .. err.pluginId .. "`)\n\n"
content = content .. "**Error Type:** " .. err.type .. "\n\n"
content = content .. "**Error Message:** " .. err.message .. "\n\n"
if err.details and type(err.details) == "table" then
content = content .. "**Details:**\n\n```\n"
-- Simplified error details display
for k, v in pairs(err.details) do
content = content .. k .. ": " .. tostring(v) .. "\n"
end
content = content .. "```\n\n"
end
-- Add suggested actions based on error type
content = content .. "**Suggested Action:** "
if err.type == "not_found" then
content = content .. "Make sure the plugin ID is correct and the plugin is installed.\n\n"
elseif err.type == "missing_dependency" then
content = content .. "Install the required dependency or update the plugin to make this dependency optional.\n\n"
elseif err.type == "incompatible_version" then
content = content .. "Update the required plugin to a compatible version.\n\n"
elseif err.type == "runtime_error" then
content = content .. "There is a bug in the plugin code. Contact the plugin author.\n\n"
else
content = content .. "Check the plugin code and dependencies.\n\n"
end
end
content = content .. "---\n\n"
content = content .. "Use the command `PDS: Reload Plugins` after fixing the issues.\n"
return content
end
-- Improved creation of problems page with better organization
function PDSCore:createProblemsPage()
-- Remove previous problems page if exists
if space.pageExists(self.config.problemsPageName) then
space.deletePage(self.config.problemsPageName)
end
-- If no errors, do not create page
if #self.errors == 0 then
return
end
-- Generate page content
local content = self:generateProblemsContent()
-- Save page
space.writePage(self.config.problemsPageName, content)
-- Notify user
if #self.errors == 1 then
editor.flashNotification("1 plugin failed to initialize. See " .. self.config.problemsPageName .. " page for details.", "error")
else
editor.flashNotification(#self.errors .. " plugins failed to initialize. See " .. self.config.problemsPageName .. " page for details.", "error")
end
-- Automatically navigate to problems page
if self.config.autoNavigateToProblems then
editor.navigate(self.config.problemsPageName)
end
end
-- Main PDSCore plugin management functions
-- Plugin discovery with better reporting
function PDSCore:discoverPlugins()
self:log("info", "Starting plugin discovery")
-- Previously collected plugins for comparison
local previousPluginCount = 0
for _ in pairs(self.plugins) do previousPluginCount = previousPluginCount + 1 end
local responses = event.dispatch("pds:discoverPlugins")
local discoveredCount = 0
for _, response in ipairs(responses or {}) do
if response and response.id and response.manifest then
-- Basic data validation
if type(response.id) ~= "string" or response.id == "" then
self:log("warn", "Skipped plugin with invalid ID")
elseif type(response.manifest) ~= "table" then
self:log("warn", "Skipped plugin", response.id, "with invalid manifest")
elseif type(response.initialize) ~= "function" then
self:log("warn", "Plugin", response.id, "has no initialization function")
else
-- Version validation and standardization
if not response.manifest.version then
self:log("warn", "Plugin", response.id, "has no version, using 0.0.1")
response.manifest.version = "0.0.1"
end
if not semver.parse(response.manifest.version) then
self:log("warn", "Plugin", response.id, "has invalid version format:", response.manifest.version)
response.manifest.version = "0.0.1"
end
-- Data standardization
response.manifest.name = response.manifest.name or response.id
response.manifest.dependencies = response.manifest.dependencies or {}
-- Add plugin to registry
self.plugins[response.id] = response
discoveredCount = discoveredCount + 1
self:log("info", "Discovered plugin:", response.manifest.name, "(", response.id, ")", "version:", response.manifest.version)
end
end
end
-- Set plugin modification flag for cache refresh
if discoveredCount ~= previousPlginCount then
self.pluginsModified = true
end
self:log("info", "Plugin discovery completed, found:", discoveredCount)
return discoveredCount
end
-- PDSCore configuration with externals
function PDSCore:configure(config)
config = config or {}
-- Deep copy settings
local function deepMerge(target, source)
for k, v in pairs(source) do
if type(v) == "table" and type(target[k]) == "table" then
deepMerge(target[k], v)
else
target[k] = v
end
end
return target
end
-- Apply settings
self.config = deepMerge(self.config, config)
-- Update dependent settings
self.debug = self.config.debug or false
return self
end
-- Improved start function with configuration
function PDSCore:start(config)
self:log("info", "Starting PDS system")
-- Apply optional configuration
if config then
self:configure(config)
end
-- Remove existing problems page
if space.pageExists(self.config.problemsPageName) then
space.deletePage(self.config.problemsPageName)
end
-- Clear system state
self.initialized = {}
self.exports = {}
self.errors = {}
-- Discover plugins
local discoveredCount = self:discoverPlugins()
if discoveredCount == 0 then
self:log("warn", "No plugins found")
return true -- Technically success, though nothing to do
end
-- Build dependency graph
local graph = self:buildDependencyGraph()
-- Check for cycles
local hasCycle, cyclePath = self:findCycles(graph)
if hasCycle then
local cycleStr = table.concat(cyclePath, " → ")
self:log("error", "Error: Detected circular dependency between plugins: " .. cycleStr)
-- Create information page about the problem with cycle details
space.writePage(self.config.problemsPageName,
"# Plugin Loading Error\n\n" ..
"Circular dependencies detected between plugins. System cannot continue.\n\n" ..
"**Dependency cycle detected:** `" .. cycleStr .. "`\n\n" ..
"Please modify one of these plugins to break the dependency cycle."
)
editor.flashNotification("Circular dependencies detected. See " .. self.config.problemsPageName .. " page", "error")
if self.config.autoNavigateToProblems then
editor.navigate(self.config.problemsPageName)
end
return false
end
-- Topologically sort plugins
local sortedIds, cycle = self:topologicalSort(graph)
if not sortedIds then
if cycle then
self:log("error", "Error: Detected circular dependency in cycle:", table.concat(cycle, " -> "))
else
self:log("error", "Error: Cannot topologically sort plugins")
end
-- Create information page about the problem
space.writePage(self.config.problemsPageName,
"# Plugin Loading Error\n\n" ..
"Cannot properly sort plugins. System cannot continue.\n\n" ..
(cycle and ("Detected cycle: " .. table.concat(cycle, " -> ") .. "\n\n") or "") ..
"Please check plugin configuration and ensure they don't create dependency cycles."
)
editor.flashNotification("Dependency problem detected. See " .. self.config.problemsPageName .. " page", "error")
if self.config.autoNavigateToProblems then
editor.navigate(self.config.problemsPageName)
end
return false
end
self:log("info", "Initialization order:", table.concat(sortedIds, ", "))
-- Initialize in sorted order
local results = self:initializePlugins(sortedIds)
-- Report results
local success = true
local successCount = 0
for id, result in pairs(results) do
if not result.success then
success = false
self:log("error", "Plugin", id, "failed to initialize:", result.error)
else
successCount = successCount + 1
self:log("info", "Plugin", id, "initialized successfully")
end
end
self:log("info", "Plugin initialization completed. Success:", successCount, "/", #sortedIds)
-- If no errors detected but there are on the list, this means sudden runtime errors
if success and #self.errors > 0 then
self:createProblemsPage()
return false
end
return success
end
-- Initialize PDS core when system is ready
event.listen {
name = "system:ready",
run = function()
PDSCore:configure({
debug = true,
logLevel = "info", -- Possible options: debug, info, warn, error
autoNavigateToProblems = true
}):start()
end
}
-- SilverBullet command to reload plugins
command.define {
name = "PDS: Reload Plugins",
description = "Clears plugin cache and reloads them from scratch",
run = function()
system.invokeCommand("System: Reload")
editor.flashNotification("Reloading PDS plugins...", "info")
PDSCore:start()
editor.flashNotification("PDS plugins have been reloaded", "info")
end
}