From 0f763ccb1f1b5436226482b2e3791c7a6fe1c854 Mon Sep 17 00:00:00 2001 From: Groctel Date: Sun, 10 Sep 2023 16:09:47 +0200 Subject: [PATCH] feat: (wip) Redesigned event publishing system --- lua/neorg.lua | 17 +-- lua/neorg/core/callbacks.lua | 40 ------ lua/neorg/core/events.lua | 101 +++++++++++++++ lua/neorg/core/init.lua | 2 +- lua/neorg/core/modules.lua | 230 +++-------------------------------- lua/neorg/core/topics.lua | 13 ++ lua/types.lua | 25 ++++ 7 files changed, 164 insertions(+), 264 deletions(-) delete mode 100644 lua/neorg/core/callbacks.lua create mode 100644 lua/neorg/core/events.lua create mode 100644 lua/neorg/core/topics.lua create mode 100644 lua/types.lua diff --git a/lua/neorg.lua b/lua/neorg.lua index 36e8ec3fa..9234c4b4c 100644 --- a/lua/neorg.lua +++ b/lua/neorg.lua @@ -5,7 +5,11 @@ -- Require the most important modules local neorg = require("neorg.core") -local config, log, modules = neorg.config, neorg.log, neorg.modules +local config, events, log, modules = neorg.config, neorg.events, neorg.log, neorg.modules + +---@class event.core.neorg_started +---Informs all modules that all core Neorg modules have been loaded and Neorg +---has completed its setup process. It doesn't contain any fields. --- This function takes in a user config, parses it, initializes everything and launches neorg if inside a .norg or .org file ---@param cfg table #A table that reflects the structure of config.user_config @@ -106,16 +110,7 @@ function neorg.org_file_entered(manual, arguments) config.started = true -- Lets the entire Neorg environment know that Neorg has started! - modules.broadcast_event({ - type = "core.started", - split_type = { "core", "started" }, - filename = "", - filehead = "", - cursor_position = { 0, 0 }, - referrer = "core", - line_content = "", - broadcast = true, - }) + events.publish("neorg_started", nil --[[@as neorg.event.neorg_started]]) -- Sometimes external plugins prefer hooking in to an autocommand vim.api.nvim_exec_autocmds("User", { diff --git a/lua/neorg/core/callbacks.lua b/lua/neorg/core/callbacks.lua deleted file mode 100644 index 23123d8a7..000000000 --- a/lua/neorg/core/callbacks.lua +++ /dev/null @@ -1,40 +0,0 @@ ---[[ - Neorg User Callbacks File - User callbacks are ways for the user to directly interact with Neorg and respond on certain events. ---]] - -local callbacks = { - callback_list = {}, -} - ---- Triggers a new callback to execute whenever an event of the requested type is executed ----@param event_name string #The full path to the event we want to listen on ----@param callback fun(event, content) #The function to call whenever our event gets triggered ----@param content_filter fun(event) #A filtering function to test if a certain event meets our expectations -function callbacks.on_event(event_name, callback, content_filter) - -- If the table doesn't exist then create it - callbacks.callback_list[event_name] = callbacks.callback_list[event_name] or {} - -- Insert the callback and content filter - table.insert(callbacks.callback_list[event_name], { callback, content_filter }) -end - ---- Used internally by Neorg to call all callbacks with an event ----@param event table #An event as returned by modules.create_event() -function callbacks.handle_callbacks(event) - -- Query the list of registered callbacks - local callback_entry = callbacks.callback_list[event.type] - - -- If the callbacks exist then - if callback_entry then - -- Loop through every callback - for _, callback in ipairs(callback_entry) do - -- If the filter event has not been defined or if the filter returned true then - if not callback[2] or callback[2](event) then - -- Execute the callback - callback[1](event, event.content) - end - end - end -end - -return callbacks diff --git a/lua/neorg/core/events.lua b/lua/neorg/core/events.lua new file mode 100644 index 000000000..35067d1e6 --- /dev/null +++ b/lua/neorg/core/events.lua @@ -0,0 +1,101 @@ +local modules = require("neorg.core.modules") +local api, fn = vim.api, vim.fn + + +---@class neorg.events +---# A simple interface to publish event samples. +--- +---## Events and event samples +---An event is a point in the flow of Neorg's execution that one of its modules +---broadcasts so that other modules can react accordingly. Events follow a simple +---publish-subscribe model where publishers send the event to all loaded modules +---and those react to the event if they are subscribed to the event's topic. +--- +---An event sample is an aggregation of data sent when an event is published, i.e. +---when the function `events.publish` is called by a module. +--- +---## Topic names +---The topic name can be namespaced by prepending as many namespaces as desired +---separated by a dot (`.`). For example, you can namespace `"my_event"` as +---`"my_namespace.my_event"`. This namespacing scheme shall never be used to +---identify the publisher of the event. If the publisher should ever require +---identification, a discriminator should be included in the payload. +--- +---## Payloads +---All samples contain a payload, whose type is identified by the sample's topic. +---A topic is a string that uniquely identifies the sample's type. When a module +---subscribes to a type of event, they subscribe to its topic. For example, a +---module can be subscribed to the topic `"module_loaded"` and not be subscribed +---to `"autocmd"`. When a module is subscribed to a topic, it knows exactly the +---fields that its corresponding sample's payload will have when it is received. +---This also implies that different topics can have the same type, but a topic's +---type must always be the same in every event sample. +--- +---When you define your event's type with LuaLS, call it `neorg.event.`. +---For example, the event whose topic name is `"neorg_started"` should be of +---type `neorg.event.neorg_started`. +--- +---### Making LuaLS aware of the type of the payload +---When you call `events.publish`, you can build the payload in-place as an +---argument for the function. If you do it this way, you can add an `@as` type +---hint after the table's closing bracket to enable completion and diagnostics: +--- +---```lua +---events.publish("core.concealer.update_region", { +--- start = start_pos[1] - 1, +--- ["end"] = end_pos[1] + 2, +---} --[[@as neorg.event.core.concealer.update_region]]) +---``` +--- +---## Notes on the publish-subscribe system +---When an event is published, it is published for every single loaded module. +---It is every module's responsibility to subscribe to the topics it is +---interested in and properly handle the samples it receives. This is integral to +---the correct functioning of the events system, since the responsibility also +---gives the subscribers the power to extend Neorg's functionality by +---implementing new behaviours based on the events published by different modules. +--- +---If an event could be published but it shouldn't be received by any subscriber, +---it is the publisher's responsibility to prevent the publication of such event. +local events = {} + + +---@class neorg.event_sample +---@field public topic string #An unique name that identifies the event's payload type +---@field public payload any #The content of the event, identified by the `topic` discriminator field +---@field public cursor_position cursor_pos #The position of the cursor when the event was published +---@field public filename string #The name of the active file when the event was published +---@field public filehead string #The absolute path of the active file's directory when the event was published +---@field public line_content string #The content of the active line when the event was published +---@field public buffer integer #The active buffer descriptor when the event was published +---@field public window integer #The active window descriptor when the event was published +---@field public mode string #Vim's mode when the event was published + + +---Publish an event that is received by all modules. See the documentation for +---`neorg.events` for more information on the publishing and subscribing rules. +---@param topic string #See event_sample::topic +---@param payload any #See event_sample::payload +function events.publish(topic, payload) + local event --[[@as neorg.event_sample]] = { + topic = topic, + payload = payload, + + -- Metadata members defining Vim's state when the event was published + -- TODO: Deprecate? These could be added to the payload on demand. + cursor_position = vim.api.nvim_win_get_cursor(0), + filename = fn.expand("%:t"), + filehead = fn.expand("%:p:h"), + line_content = api.nvim_get_current_line(), + buffer = api.nvim_get_current_buf(), + window = api.nvim_get_current_win(), + mode = api.nvim_get_mode().mode, + } + + for _, module in pairs(modules.loaded_modules) do + module:on_event(event) + end +end + + +return events diff --git a/lua/neorg/core/init.lua b/lua/neorg/core/init.lua index 3d5dc635c..9361c63f9 100644 --- a/lua/neorg/core/init.lua +++ b/lua/neorg/core/init.lua @@ -1,6 +1,6 @@ local neorg = { - callbacks = require("neorg.core.callbacks"), config = require("neorg.core.config"), + events = require("neorg.core.events"), lib = require("neorg.core.lib"), log = require("neorg.core.log"), modules = require("neorg.core.modules"), diff --git a/lua/neorg/core/modules.lua b/lua/neorg/core/modules.lua index 828b8e179..eb0c8739e 100644 --- a/lua/neorg/core/modules.lua +++ b/lua/neorg/core/modules.lua @@ -6,8 +6,8 @@ -- This file contains the base module implementation --]] -local callbacks = require("neorg.core.callbacks") local config = require("neorg.core.config") +local events = require("neorg.core.events") local log = require("neorg.core.log") local utils = require("neorg.core.utils") @@ -29,7 +29,12 @@ function modules.create(name, imports) -- Invoked whenever an event that the module has subscribed to triggers -- callback function with a "event" parameter - on_event = function() end, + on_event = function(self, event) + local subscription = self.subscriptions[event.topic] + if subscription ~= nil and subscription.active then + subscription.handler(event) + end + end, -- Invoked after all plugins are loaded neorg_post_load = function() end, @@ -82,22 +87,13 @@ function modules.create(name, imports) }, -- Event data regarding the current module - events = { - subscribed = { -- The events that the module is subscribed to - --[[ - ["core.test"] = { -- The name of the module that has events bound to it - ["test_event"] = true, -- Subscribes to event core.test.events.test_event - - ["other_event"] = true -- Subscribes to event core.test.events.other_event - } - --]] - }, - defined = { -- The events that the module itself has defined - --[[ - ["my_event"] = { event_data } -- Creates an event of type category.module.events.my_event - --]] - }, - }, + -- TODO: Actually define as part of the type system + subscriptions = { --[[ The events that the module is subscribed to + ["topic_name"] = { -- The name of a topic this module is subscribed to + active = true, -- Whether the subscription is active + handler = function(self, event) end, -- Function that handles the event + } + --]]}, -- If you ever require a module through the return value of the setup() function, -- All of the modules' public APIs will become available here @@ -371,12 +367,14 @@ function modules.load_module_from_table(module) -- previous module into our new one. This allows for practically seamless hotswapping, as it allows you to retain the data -- of the previous module. if loaded_module.replace_merge then - module = vim.tbl_deep_extend("force", module, { + local replaced_module = vim.tbl_deep_extend("force", module, { private = module_to_replace.private, config = module_to_replace.config, public = module_to_replace.public, events = module_to_replace.events, }) + assert(replaced_module ~= nil) + module = replaced_module end -- Set the special module.replaced flag to let everyone know we've been hotswapped before @@ -400,18 +398,7 @@ function modules.load_module_from_table(module) -- local msg = ("%fms"):format((vim.loop.hrtime() - start) / 1e6) -- vim.notify(msg .. " " .. module.name) - modules.broadcast_event({ - type = "core.module_loaded", - split_type = { "core", "module_loaded" }, - filename = "", - filehead = "", - cursor_position = { 0, 0 }, - referrer = "core", - line_content = "", - content = module, - broadcast = true, - }) - + events.publish("module_loaded", module.name --[[@as neorg.event.module_loaded]]) return true end @@ -564,186 +551,5 @@ function modules.await(module_name, callback) end) end --- TODO: What goes below this line until the next notice used to belong to modules --- We need to find a way to make these functions easier to maintain - ---[[ --- NEORG EVENT FILE --- This file is responsible for dealing with event handling and broadcasting. --- All modules that subscribe to an event will receive it once it is triggered. ---]] - ---- The working of this function is best illustrated with an example: --- If type == 'core.some_plugin.events.my_event', this function will return { 'core.some_plugin', 'my_event' } ----@param type string #The full path of a module event -function modules.split_event_type(type) - local start_str, end_str = type:find("%.events%.") - - local split_event_type = { type:sub(0, start_str - 1), type:sub(end_str + 1) } - - if #split_event_type ~= 2 then - log.warn("Invalid type name:", type) - return - end - - return split_event_type -end - ---- Returns an event template defined in module.events.defined ----@param module table #A reference to the module invoking the function ----@param type string #A full path to a valid event type (e.g. 'core.module.events.some_event') -function modules.get_event_template(module, type) - -- You can't get the event template of a type if the type isn't loaded - if not modules.is_module_loaded(module.name) then - log.info("Unable to get event of type", type, "with module", module.name) - return - end - - -- Split the event type into two - local split_type = modules.split_event_type(type) - - if not split_type then - log.warn("Unable to get event template for event", type, "and module", module.name) - return - end - - log.trace("Returning", split_type[2], "for module", split_type[1]) - - -- Return the defined event from the specific module - return modules.loaded_modules[module.name].events.defined[split_type[2]] -end - ---- Creates a deep copy of the modules.base_event event and returns it with a custom type and referrer ----@param module table #A reference to the module invoking the function ----@param name string #A relative path to a valid event template -function modules.define_event(module, name) - -- Create a copy of the base event and override the values with ones specified by the user - - local new_event = { - type = "core.base_event", - split_type = {}, - content = nil, - referrer = nil, - broadcast = true, - - cursor_position = {}, - filename = "", - filehead = "", - line_content = "", - buffer = 0, - window = 0, - mode = "", - } - - if name then - new_event.type = module.name .. ".events." .. name - end - - new_event.referrer = module.name - - return new_event -end - ---- Returns a copy of the event template provided by a module ----@param module table #A reference to the module invoking the function ----@param type string #A full path to a valid event type (e.g. 'core.module.events.some_event') ----@param content any #The content of the event, can be anything from a string to a table to whatever you please ----@return table #New event -function modules.create_event(module, type, content) - -- Get the module that contains the event - local module_name = modules.split_event_type(type)[1] - - -- Retrieve the template from module.events.defined - local event_template = modules.get_event_template(modules.loaded_modules[module_name] or { name = "" }, type) - - if not event_template then - log.warn("Unable to create event of type", type, ". Returning nil...") - return - end - - -- Make a deep copy here - we don't want to override the actual base table! - local new_event = vim.deepcopy(event_template) - - new_event.type = type - new_event.content = content - new_event.referrer = module.name - - -- Override all the important values - new_event.split_type = modules.split_event_type(type) - new_event.filename = vim.fn.expand("%:t") - new_event.filehead = vim.fn.expand("%:p:h") - new_event.cursor_position = vim.api.nvim_win_get_cursor(0) - new_event.line_content = vim.api.nvim_get_current_line() - new_event.referrer = module.name - new_event.broadcast = true - new_event.buffer = vim.api.nvim_get_current_buf() - new_event.window = vim.api.nvim_get_current_win() - new_event.mode = vim.api.nvim_get_mode().mode - - return new_event -end - ---- Sends an event to all subscribed modules. The event contains the filename, filehead, cursor position and line content as a bonus. ----@param event table #An event, usually created by modules.create_event() ----@param callback function? #A callback to be invoked after all events have been asynchronously broadcast -function modules.broadcast_event(event, callback) - -- Broadcast the event to all modules - if not event.split_type then - log.error("Unable to broadcast event of type", event.type, "- invalid event name") - return - end - - -- Let the callback handler know of the event - callbacks.handle_callbacks(event) - - -- Loop through all the modules - for _, current_module in pairs(modules.loaded_modules) do - -- If the current module has any subscribed events and if it has a subscription bound to the event's module name then - if current_module.events.subscribed and current_module.events.subscribed[event.split_type[1]] then - -- Check whether we are subscribed to the event type - local evt = current_module.events.subscribed[event.split_type[1]][event.split_type[2]] - - if evt ~= nil and evt == true then - -- Run the on_event() for that module - current_module.on_event(event) - end - end - end - - -- Because the broadcasting of events is async we allow the event broadcaster to provide a callback - -- TODO: deprecate - if callback then - callback() - end -end - ---- Instead of broadcasting to all loaded modules, send_event() only sends to one module ----@param recipient string #The name of a loaded module that will be the recipient of the event ----@param event table #An event, usually created by modules.create_event() -function modules.send_event(recipient, event) - -- If the recipient is not loaded then there's no reason to send an event to it - if not modules.is_module_loaded(recipient) then - log.warn("Unable to send event to module", recipient, "- the module is not loaded.") - return - end - - -- Set the broadcast variable to false since we're not invoking broadcast_event() - event.broadcast = false - - -- Let the callback handler know of the event - callbacks.handle_callbacks(event) - - -- Get the recipient module and check whether it's subscribed to our event - local mod = modules.loaded_modules[recipient] - - if mod.events.subscribed and mod.events.subscribed[event.split_type[1]] then - local evt = mod.events.subscribed[event.split_type[1]][event.split_type[2]] - - -- If it is then trigger the module's on_event() function - if evt ~= nil and evt == true then - mod.on_event(event) - end - end -end return modules diff --git a/lua/neorg/core/topics.lua b/lua/neorg/core/topics.lua new file mode 100644 index 000000000..b0c3a7fa0 --- /dev/null +++ b/lua/neorg/core/topics.lua @@ -0,0 +1,13 @@ +---@meta + + +---@alias neorg.event.module_loaded string +---Informs that a new module has been loaded and added to Neorg's environment. +---Since only the module's name is published, a module that would like to gather +---more information about the newly loaded module should retrieve it by calling +---`neorg.modules.get()`. +---TODO: Make this a reality! + + +---@alias neorg.event.neorg_started nil +---Informs that Neorg has finished loaded. Its payload is empty. diff --git a/lua/types.lua b/lua/types.lua new file mode 100644 index 000000000..e58859579 --- /dev/null +++ b/lua/types.lua @@ -0,0 +1,25 @@ +---@meta + + +-- Pure data structure types +-------------------------------------------------------------------------------- + +--- Shorthand type for string-keyed tables +---@class dict: { [string]: T } + +--- Shorthand for integer-keyed tables, commonly known as arrays +---@class array: { [integer]: T } +--- NOTE: As of the time of writing, LuaLS hasn't implemented static arrays. +--- You can use the `array` class for the time being, but leave a note when +--- the object should have a fixed size. For example, if an array object will +--- always have three strings, you can leave a note typing it this way: +--- string[3] +--- See: https://github.com/sumneko/lua-language-server/issues/1081 + + +-- Types inherited from Vim's API +-------------------------------------------------------------------------------- + +---@class cursor_pos: array +---@see nvim_win_get_cursor +--- NOTE: integer[2]