feat: initial implementation

A first working, publishable version of colorsaver.nvim.

This plugin connects to the ColorScheme event to detect changes to the
colorscheme. Any time that changes, it saves the colorscheme's name to a
local file. In addition to that, it can watch that local file for
changes. This way, when one `nvim` process changes the colorscheme, all
other instances will automatically reload the colorscheme.
This commit is contained in:
swaits committed 2023-11-07 11:22:13 -07:00
commit 70f0ad0ab8
10 files changed
+835

No files matched your search

+103
View File
@@ -0,0 +1,103 @@
-- autocommands.lua
--- AutocommandGroup module for managing groups of autocommands in Neovim.
-- This module encapsulates the creation and management of autocommand groups and their definitions.
local AutocommandGroup = {}
AutocommandGroup.__index = AutocommandGroup
--- Constructor for creating a new AutocommandGroup.
-- @param name string The name of the autocommand group.
-- @param definitions table A table of autocommand definitions.
-- @return table A new instance of AutocommandGroup.
function AutocommandGroup.new(name, definitions)
-- Validate that 'name' is provided and is a string.
assert(type(name) == "string", "Autocommand group name must be a string")
-- Create an instance of AutocommandGroup and set its properties.
local self = setmetatable({}, AutocommandGroup)
self.name = name
self.definitions = definitions or {}
-- Create a new autocommand group in Neovim, clearing any existing autocommands in the group.
self.group_id = vim.api.nvim_create_augroup(name, { clear = true })
-- Set or update the autocommand definitions for the group.
self:set_definitions(self.definitions)
return self
end
--- Sets or updates the autocommand definitions for the group.
-- @param definitions table The autocommand definitions to set or update.
function AutocommandGroup:set_definitions(definitions)
-- Validate that 'definitions' is a table.
assert(type(definitions) == "table", "Autocommand definitions must be a table")
-- Clear existing autocommands from the group to prepare for new definitions.
vim.api.nvim_clear_autocmds({ group = self.group_id })
-- Iterate over each definition and create an autocommand.
for _, def in ipairs(definitions) do
-- Perform checks to ensure the definition structure is valid.
assert(def.events, "Autocommand definition is missing 'events' key")
assert(
type(def.events) == "string" or type(def.events) == "table",
"'events' must be a string or a table"
)
assert(def.patterns, "Autocommand definition is missing 'patterns' key")
assert(
type(def.patterns) == "string" or type(def.patterns) == "table",
"'patterns' must be a string or a table"
)
assert(
def.callback or def.command,
"Autocommand definition must have either 'callback' or 'command'"
)
-- Check for 'callback' and 'command' specifics and validate their types.
if def.callback then
assert(type(def.callback) == "function", "'callback' must be a function")
end
if def.command then
assert(type(def.command) == "string", "'command' must be a string")
end
-- Validate optional keys if they are present.
if def.once then
assert(type(def.once) == "boolean", "'once' must be a boolean")
end
if def.nested then
assert(type(def.nested) == "boolean", "'nested' must be a boolean")
end
-- Create the autocommand in Neovim with the provided definition.
vim.api.nvim_create_autocmd(def.events, {
group = self.group_id,
pattern = def.patterns,
callback = def.callback,
command = def.command,
once = def.once,
nested = def.nested,
})
end
-- Update the stored definitions.
self.definitions = definitions
end
--- Clears the autocommand group, effectively disabling all its autocommands.
function AutocommandGroup:clear()
-- Clear all autocommands associated with the group in Neovim.
vim.api.nvim_clear_autocmds({ group = self.group_id })
end
--- Restores the autocommand group using the stored definitions.
function AutocommandGroup:restore()
-- Reapply the stored autocommand definitions.
self:set_definitions(self.definitions)
end
-- Module table for exporting the constructor.
local M = {
new = AutocommandGroup.new,
}
return M
+98
View File
@@ -0,0 +1,98 @@
-- config.lua
--- Configuration module for handling user settings and defaults.
-- This module provides validation and fallbacks for user-specified configuration.
local M = {}
--- Define default configuration values with inline documentation.
-- These defaults are used as a fallback if the user does not provide these settings or if they are invalid.
local defaults = {
--- log_level: Sets the logging level for the module's output.
-- @field log_level Acceptable values are "debug", "info", "warn", "error".
log_level = "warn",
--- debounce_ms: Sets the debounce time in milliseconds for file watching.
-- @field debounce_ms Accepts any integer greater than or equal to 50. If experiencing issues, consider increasing this value.
debounce_ms = 100,
--- filename: The name of the file where the colorscheme will be saved.
-- @field filename Note that the file is always stored in the "data" directory, which is usually ~/.local/share/nvim/
filename = "colorsaver",
--- auto_load: If true, any colorscheme changes from one instance of nvim will be automatically loaded by all other instances of nvim.
-- @field auto_load
auto_load = true,
}
--- Validates the logging level.
-- @param level string The log level to validate.
-- @return boolean Whether the log level is valid.
local function is_log_level_valid(level)
local valid_levels = { debug = true, info = true, warn = true, error = true }
return valid_levels[level:lower()] ~= nil
end
--- Validates the debounce time.
-- @param ms number The debounce time in milliseconds to validate.
-- @return boolean Whether the debounce time is valid.
local function is_debounce_ms_valid(ms)
return type(ms) == "number" and ms >= 50
end
--- Validates the filename.
-- @param name string The filename to validate.
-- @return boolean Whether the filename is valid.
local function is_filename_valid(name)
return type(name) == "string" and name ~= ""
end
--- Validates the auto_load value.
-- @param value boolean The auto_load value to validate.
-- @return boolean Whether the auto_load is valid.
local function is_auto_load_valid(value)
return type(value) == "boolean"
end
--- Checks for invalid keys in the user configuration and logs a warning for each.
-- @param user_config table The user configuration to check.
local function check_for_invalid_keys(user_config)
for key, _ in pairs(user_config) do
if defaults[key] == nil then
log:warn("Ignoring invalid configuration key '" .. key .. "'.")
user_config[key] = nil
end
end
end
--- Merges user configuration with defaults and validates the result.
-- @param user_config table The user-specified configuration.
-- @param logger table The logger instance to be used for logging messages.
-- @return table The validated and merged configuration.
function M.setup(user_config, logger)
-- Store the logger after ensuring it's provided.
assert(logger, "Logger instance must be provided for configuration setup.")
log = logger
-- Check if the provided user_config is a table.
if user_config ~= nil and type(user_config) ~= "table" then
log:error("Provided user_config must be a table")
return
end
-- Remove invalid keys from the user configuration.
check_for_invalid_keys(user_config or {})
-- Deeply merge the defaults with the user configuration, prioritizing user settings.
local config = vim.tbl_deep_extend("force", {}, defaults, user_config or {})
-- Validate and apply individual configuration settings.
config.log_level = is_log_level_valid(config.log_level) and config.log_level or defaults.log_level
config.debounce_ms = is_debounce_ms_valid(config.debounce_ms) and config.debounce_ms
or defaults.debounce_ms
config.filename = is_filename_valid(config.filename) and config.filename or defaults.filename
config.auto_load = is_auto_load_valid(config.auto_load) and config.auto_load or defaults.auto_load
-- Return the validated and potentially corrected configuration.
return config
end
return M
+50
View File
@@ -0,0 +1,50 @@
-- file_watcher.lua
--- FileWatcher module for watching file system events.
-- This module provides the ability to watch for changes in a specified file and execute a callback when changes occur.
local FileWatcher = {}
FileWatcher.__index = FileWatcher
--- Constructor for the FileWatcher object.
-- @param path string The file system path to watch.
-- @param callback function The callback function to execute when a change is detected.
-- @return table The constructed FileWatcher object.
function FileWatcher.new(path, callback)
-- Validate the input parameters to ensure correct types.
assert(type(path) == "string", "Path must be a string")
assert(type(callback) == "function", "Callback must be a function")
-- Set up the FileWatcher object.
local self = setmetatable({}, FileWatcher)
self.path = path
self.callback = callback
-- Initialize the file system watcher with libuv.
self.fs_watcher = vim.loop.new_fs_event()
return self
end
--- Starts watching the file system event.
-- This method activates the file watcher, which will invoke the callback on file changes.
function FileWatcher:start()
-- Ensure the file system watcher starts monitoring for changes.
-- Empty options table and vim.schedule_wrap to ensure the callback is called in the main loop.
self.fs_watcher:start(self.path, {}, vim.schedule_wrap(self.callback))
end
--- Stops watching the file system event.
-- This method deactivates the file watcher, preventing the callback from being invoked.
function FileWatcher:stop()
-- If the watcher is active, stop it.
if self.fs_watcher then
self.fs_watcher:stop()
end
end
--- Restarts watching the file system event.
-- This method stops and then restarts the file watcher.
function FileWatcher:restart()
-- Stop and then start the watcher to refresh the monitoring.
self:stop()
self:start()
end
return FileWatcher
+219
View File
@@ -0,0 +1,219 @@
local M = {}
-- load modules
local autocommands = require("colorsaver.autocommands")
local config = require("colorsaver.config")
local file_watcher = require("colorsaver.file_watcher")
local logger = require("colorsaver.logger")
local utils = require("colorsaver.utils")
-- our module local data (and these are just placeholder values, they're overwritten below)
local autocmd_group = nil
local color_file_path = vim.fn.stdpath("data") .. "/colorsaver"
local colorfile_watcher = nil
local log = logger.new("warn", "colorsaver")
--- save_colorscheme(new_scheme)
--
-- This function saves a new colorscheme to a file. If no new_scheme is provided, it saves the default colorscheme.
--
-- Parameters:
-- - new_scheme (string): The name of the new colorscheme to save.
--
-- Returns:
-- - success (boolean): true if the colorscheme was saved successfully, false otherwise.
--
-- Raises:
-- - None
--
-- Example usage:
-- save_colorscheme("my_colorscheme")
--
local function save_colorscheme(new_scheme)
local colorscheme_name = new_scheme
if not vim.tbl_contains(vim.fn.getcompletion("", "color"), colorscheme_name) then
log:warn("Didn't find " .. colorscheme_name .. " in the list of colorschemes")
return false
end
local file, err = io.open(color_file_path, "w")
if not file then
log:error("Failed to open " .. color_file_path .. " for writing: " .. err)
return false
end
local success, write_err = pcall(function()
file:write(colorscheme_name)
end)
local close_ok, close_err = pcall(function()
file:close()
end)
if not success then
log:error("Failed to write colorscheme to " .. color_file_path .. ": " .. write_err)
return false
elseif not close_ok then
log:error("Failed to close the file " .. color_file_path .. ": " .. close_err)
return false
end
return true
end
--- load_colorscheme()
--
-- This function loads a colorscheme for the current session in Neovim. It first
-- attempts to read the colorscheme from a specified file path. If the file exists
-- and is not empty, the content of the file is used as the colorscheme.
--
-- Parameters:
-- None
--
-- Returns:
-- success (boolean): true if the colorscheme was loaded successfully, false otherwise.
--
-- Raises:
-- None
--
-- Example usage:
-- load_colorscheme()
--
local function load_colorscheme()
local colorscheme = M.default -- Fallback to default colorscheme
local file, err = io.open(color_file_path, "r")
if not file then
log:warn("Failed to open " .. color_file_path .. " for reading: " .. err)
return false
end
local content, read_err = file:read("*a")
if read_err then
log:error("Failed to read from " .. color_file_path .. ": " .. read_err)
file:close()
return false
end
local close_ok, close_err = file:close()
if not close_ok then
log:error("Failed to close " .. color_file_path .. ": " .. close_err)
return false
end
colorscheme = content ~= "" and content or colorscheme
if not (colorscheme and colorscheme ~= "") then
log:error("Invalid colorscheme name found in " .. color_file_path .. ".")
return false
end
-- disable ColorScheme autocmd so we don't get into a bad loop; this is
-- because we're about to call `colorscheme` which will trigger ColorScheme
-- and cause us to write the file again, thus retriggering the
-- load_colorscheme function since the file changed... bad loop
if autocmd_group then
autocmd_group:clear()
end
log:info("Switching to '" .. colorscheme .. "'")
local ok, cmd_err = pcall(vim.api.nvim_command, "colorscheme " .. colorscheme)
if not ok then
log:error("Failed to apply colorscheme '" .. colorscheme .. "': " .. cmd_err)
return false
end
-- and re-enable the autocommands
if autocmd_group then
autocmd_group:restore()
end
return true
end
--- Callback function for file change events.
--
-- This function is called when a file change event is triggered. It handles
-- the event by reloading the colorscheme from the updated file. If there is an
-- error, it logs the error message and stops the event.
--
-- Parameters:
-- - err (string|nil) The error message, if any. If there is no error, this parameter is nil.
-- - filename (string) The name of the file that triggered the event.
-- - events (table) A table containing information about the events that occurred.
--
-- Returns:
-- None
--
-- Raises:
-- None
--
local function on_change(err, filename)
vim.schedule(function()
if err then
-- If there's an error, notify and stop the event
log:error("Error watching file " .. tostring(filename) .. ": " .. tostring(err))
return
end
-- Reload the colorscheme from the updated file
load_colorscheme()
end)
end
--- Sets up the ColorSaver module with the provided options.
--
-- Parameters:
-- - opts (table): A table containing the options for configuring the ColorSaver module. The following keys are supported:
-- - filename (string): The name of the file where the colorscheme will be saved.
-- - log_level (string): The log level for the ColorSaver module.
-- - debounce_ms (number): The debounce time in milliseconds for saving the colorscheme.
-- - auto_load (boolean): Whether to enable autoloading of the colorscheme file.
--
-- Returns:
-- None
--
-- Raises:
-- None
--
-- This function sets up the ColorSaver module by performing the following steps:
-- 1. Extracts and validates the options provided.
-- 2. Sets up local data, including the file path for saving the colorscheme and a logger instance.
-- 3. Loads the colorscheme at startup.
-- 4. Captures the new colorscheme and saves it on the "ColorScheme" event.
-- 5. Sets up autoloading of the colorscheme file if enabled.
--
function M.setup(opts)
-- Extract options and validate them
M.config = config.setup(opts, log)
-- Set up some local data
color_file_path = vim.fn.stdpath("data") .. "/" .. M.config.filename
log = logger.new(M.config.log_level, "colorsaver")
-- Load the colorscheme at startup.
load_colorscheme()
-- Capture the new colorscheme and save it on the ColorScheme event.
local debounced_save_colorscheme = utils.debounce(save_colorscheme, M.config.debounce_ms)
autocmd_group = autocommands.new("ColorSaverGroup", {
{
events = { "ColorScheme" },
patterns = { "*" },
callback = function(info)
vim.schedule(function()
debounced_save_colorscheme(info.match) -- info.match is the colorscheme parameter (i.e. name)
end)
end,
},
})
-- Setup autoloading if enabled
if M.config.auto_load then
local debounced_on_change = utils.debounce(on_change, M.config.debounce_ms)
colorfile_watcher = file_watcher.new(color_file_path, debounced_on_change)
colorfile_watcher:start()
end
end
return M
+65
View File
@@ -0,0 +1,65 @@
--- Logger module for neovim that provides a simple logging interface.
-- @module Logger
local Logger = {}
Logger.__index = Logger
--- Log levels with corresponding neovim's vim.log.levels.
local log_levels = {
debug = vim.log.levels.DEBUG,
info = vim.log.levels.INFO,
warn = vim.log.levels.WARN,
error = vim.log.levels.ERROR,
}
--- Constructor for Logger object.
-- @param min_level The minimum log level to output messages for.
-- @param prefix A prefix to prepend to all log messages.
-- @return A new Logger instance with specified level and prefix.
function Logger.new(min_level, prefix)
local self = setmetatable({}, Logger)
self.min_level = log_levels[min_level] or log_levels.info -- Fallback to info level if unspecified
self.prefix = prefix or "Logger" -- Default prefix if none provided
return self
end
--- Sets the minimum log level.
-- Only log messages at this level or higher will be output.
-- @param min_level The minimum log level string ("debug", "info", "warn", "error").
function Logger:set_level(min_level)
self.min_level = log_levels[min_level] or self.min_level -- Keep current level if the new level is invalid
end
--- Internal function to process the log message.
-- @param level The log level of the message.
-- @param message The log message.
function Logger:_log(level, message)
if level >= self.min_level then
vim.notify(self.prefix .. ": " .. message, level)
end
end
--- Logs a message at DEBUG level.
-- @param message The message to log.
function Logger:debug(message)
self:_log(log_levels.debug, "DEBUG: " .. message)
end
--- Logs a message at INFO level.
-- @param message The message to log.
function Logger:info(message)
self:_log(log_levels.info, "INFO: " .. message)
end
--- Logs a message at WARN level.
-- @param message The message to log.
function Logger:warn(message)
self:_log(log_levels.warn, "WARN: " .. message)
end
--- Logs a message at ERROR level.
-- @param message The message to log.
function Logger:error(message)
self:_log(log_levels.error, "ERROR: " .. message)
end
return Logger
+52
View File
@@ -0,0 +1,52 @@
--- Utility functions for debouncing and validating colorschemes.
-- @module util
local M = {}
--- Debounces a function call. This is useful to prevent a function from
-- being called too frequently, delaying its execution until after a certain
-- amount of time has elapsed since the last time it was invoked.
-- @param func function The function to debounce.
-- @param delay number The time delay in milliseconds.
-- @return function A new debounced function.
function M.debounce(func, delay)
assert(type(func) == "function", "Invalid argument 'func': function expected")
assert(type(delay) == "number", "Invalid argument 'delay': number expected")
local timer = nil
return function(...)
local args = { ... } -- Capture any arguments passed to the function.
if timer then
timer:stop() -- Stop any existing timer.
timer:close() -- Clean up the timer resources.
end
-- Create a new timer.
timer = vim.loop.new_timer()
-- Start the timer with the specified delay.
timer:start(
delay,
0,
vim.schedule_wrap(function()
func(unpack(args)) -- Call the original function with the captured arguments.
timer:stop() -- Stop the timer.
timer:close() -- Clean up the timer resources.
timer = nil -- Clear the timer variable.
end)
)
end
end
--- Validates whether the provided colorscheme name is available in Neovim.
-- This function checks against the list of valid colorschemes that Neovim knows about.
-- @param colorscheme string The colorscheme name to validate.
-- @return boolean True if the colorscheme is valid, false otherwise.
function M.is_colorscheme_valid(colorscheme)
assert(type(colorscheme) == "string", "Invalid argument 'colorscheme': string expected")
-- Check if the colorscheme exists in the list of completions for "color".
return vim.tbl_contains(vim.fn.getcompletion("", "color"), colorscheme)
end
return M