If you find this work useful, don't forget to give it a GitHub ⭐ to help others find and trust it!
Human-readable cron expressions in Neovim
Cronex is a Neovim plugin to render in-line, human-readable explanations of cron expressions:
Here's a short introduction video.
This plugin is not a cron expression parser/checker by itself. Cronex is rather the "client" that allows the Neovim user to integrate and customize "servers" (cron expression "explainers") in a flexible fashion. There are several implementations of those out there (see below). You can use any of those with Cronex.
You will need a cron expression explainer installed.
The default is cronstrue, which is the one used by the vscode package cron-explained
.
Install the cronstrue
library and make sure that the command cronstrue
is available in the environment where your buffer is being shown.
That will use the cronstrue
library under the hood to generate the explanations.
Using lazy.nvim
-- init.lua:
{
'fabridamicelli/cronex.nvim',
opts = {},
}
-- Or
-- plugins/cronex.lua:
return {
'fabridamicelli/cronex.nvim',
opts = {},
}
Using vim-plug
call plug#begin()
Plug 'fabridamicelli/cronex.nvim'
call plug#end()
lua <<EOF
require("cronex").setup({})
EOF
Calling setup makes the explainer available and set explanations when leaving insert mode.
Entering insert mode clears the explanations.
Cronex can be also disabled/enabled on any file (see Commands).
The setup will make the following commands available:
Command | Description |
---|---|
CronExplainedDisable |
Turn off the explanations permanently |
CronExplainedEnable |
Turn on the explanations again (regardless of filetype) |
The plugin consists of three building blocks:
Module | Description |
---|---|
extractor |
Logic to extract cron expressions from current buffer |
explainer |
Program that will parse and explain the cron expressions |
format |
Postprocess the output string produced by the explainer for final display |
Default configuration:
require("cronex").setup({
-- The plugin will automatically start (with autocommand) for these types of files.
-- User can manually on any filetype turn explanations on(off) with the commands CronExplainedEnable(CronExplainedDisable)
file_patterns = { "*.yaml", "*.yml", "*.tf", "*.cfg", "*.config", "*.conf" },
extractor = { -- Configuration on how to extract cron expressions goes here:
-- cron_from_line: Function to search cron expression in line
cron_from_line = require("cronex.cron_from_line").cron_from_line,
-- extract: Function returning a table with pairs (line_number, cron)
extract = require("cronex.extract").extract,
},
explainer = { -- Configuration on how to explain one cron expression goes here
-- Command to call an external program that will translate the cron expression
-- eg: "* * * * *" -> Every minute
-- Any command that is available in your command line can be used here.
-- examples:
-- "/path/to/miniconda3/envs/neovim/bin/cronstrue" (point to a conda virtualenv)
-- "python explainer.py" (assuming you have such a python script available)
cmd = "cronstrue",
-- Optional arguments to pass to the command
-- eg: "/path/to/a/go/binary" (assuming you have a go binary)
-- args = { "-print-all" } (assuming the program understands the flag 'print-all')
args = {}
},
-- Configure the post-processing of the explanation string.
-- eg: transform "* * * * *": Every minute --to--> Every minute
-- using require("cronex.format").all_after_colon,
format = function(s)
return s
end
})
To embed the above configuration code snippet in a .vim
file
(for example in init.vim
),
wrap it in lua << EOF code-snippet EOF
:
lua << EOF
require('cronex').setup{
-- ...
}
EOF
Logic of the default extractor can be found here in /cronex/cron_from_line.lua
.
Default extractor searches for at most 1 expression per line of length 7, 6 or 5 (in that order).
But Cronex allows the user to hook in and swap this by any arbitrary logic.
The extractor has 2 parts: cron_from_line
and extract
, both are functions.
You can swap any or both of the two with custom functions, provided you respect the following interfaces:
cron_from_line
: Function with signature string -> string|nil
.
Returns the cron expression if found (else nil
)
extract
: Function with signature function -> table
The input function
processes each buffer line (may be identity, i.e. just return line as is).
Output table
of pairs (line_number
, cron_expression
), empty if no cron expressions found.
Here's a toy example on how to customize the functions, that will set "line <LINE_NUMBER> says --> hello world" on every line of the buffer:
require("cronex").setup({
extractor = {
cron_from_line = function(_)
-- This commented block is what you would actually do in a real scenario
-- local cron = do_something_to_extract_cron(buffer_line)
-- if cron then
-- return cron
-- end
-- return nil
return "hello world" -- let's hard-code something :)
end,
extract = function(cron_from_line)
local t = {}
for i, line in ipairs(vim.api.nvim_buf_get_lines(0, 0, -1, false)) do
t[i - 1] = string.format("line %s says --> %s ", i - 1, cron_from_line(line))
end
return t
end
},
explainer = {
cmd = "echo" -- just echo the what extract produces
},
})
Under the hood, cron_from_line
will be passed to extract
like so:
extract(cron_from_line)
This allows you to plug a custom function to extract cron from line and still use the default extract
function
You may even just set cron_from_line
to nil
and use the extract
function to send the whole buffer to another program from which you capture the output.
All that matters is that extract
returns the table with pairs (line_number
, cron_expression
).
For example:
{
require("cronex").setup({
extractor = {
cron_from_line = nil,
extract = function(_)
local t = {}
local out = send_buffer_to_external_program_and_collect_crons()
for lnum, cron in out do
t[lnum] = cron
end
return t
end
}
})
}
As already mentioned above, Cronex is the integrates the functionality of external cron expression explainers into Neovim. There are several implementations of those out there.
More generally, it's up to the user which explainer program to use in the background.
Cronex will call such program via the command (cmd
), collect the output and pass it along to Neovim.
This is the default:
require("cronex").setup({
explainer = {
cmd = "cronstrue",
args = {}
}
})
For example, you can have cronstrue
installed in a conda virtualenv.
If the virtualenv is active, everything should work out of the box.
But you may not want to install cronstrue
in every virtualenv, so you can have only one central environment with cronstrue
installed and point to that binary explicitly in the config:
{
require("cronex").setup({
explainer = {
cmd = "/home/username/miniconda3/envs/neovim/bin/cronstrue"
})
}
In fact cmd
can call anything that knows how to deal with the cron expression.
For example, calling a go program:
{
require("cronex").setup({
explainer = {
cmd = { "go", "run", "/path/to/go/app/cmd/module/main.go" },
-- or if you pre-compiled it (recommended):
-- cmd = { "/path/to/go/app/cmd/module/binary" },
args = { "-arg1", "-arg2" }
}
})
}
Here are a few of those third-party libraries as well as the Cronex command to use them:
cronstrue:
This is the default explainer by Cronex and the very same library used by the vscode package cron-explained
.
You need first install the cronstrue
library and make sure that the command cronstrue
is available in the environment where your buffer is being shown.
For example, you can use it inside of a Python virtual environment (in this case managed by conda to install nodejs
):
conda create -n venv
conda activate venv
conda install nodejs -c conda-forge
npm install cronstrue
After that cronstrue
will only be installed inside venv
(thus only available there).
hcron:
This explainer is written in Go and much considerably faster than the default.
But it is not as widely used and the project does not seem to be that well maintained.
Recommendation: Compile the binary
Here's a (non-exhaustive) overview cron explainers out there:
We might want to modify the output from the third-party explainer libraries. For example, some explainers show the input as well in the output like so:
"* * * * *": Every minute
In that case, you could use the function require("cronex.format").all_after_colon
) to transform the output to just show "Every minute".
But the user can do any other transformation by defining a lua function, for example:
{
require("cronex").setup({
format = function(explanation)
local colon = string.find(explanation, ": ")
if colon then
return "Human-readable:" .. string.sub(explanation, colon + 2)
end
return explanation
end
})
}
That will transform it like this:
"* * * * *": Every minute --> Human-readable: Every minute
The current extract
logic is a bit rudimentary (partly because regex in lua are a bit trickier than normal (at least for me).
Any improvement along those lines is more than welcome.
The call to the explainer is a blocking operation.
While testing I found that to be a problem only if there are unrealistically many cron expressions in the buffer.
Also, the Go implementation of the explainer is so fast that even having hundreds of expressions in a buffer everything runs decently fast.
In short, my guess is that almost no user (if any at all) will notice this.
Having said that, a few potential ideas to improve performance:
- Accelerating extraction, for example, by using
ripgrep
to extract all crons in one shot (instead of iterating over lines) - Implementing the explainer in pure lua to avoid external calls
- Default behaviour considers only 1 cron expression is per line. So having repeated expressions in one line will result in no explanation at all. I haven't seen use-cases where it makes sense to have more than one, but I'd be open to consider it if that makes sense.
- If encountering problems with an expression, disable the format in order to see the exact output coming from the explainer