Skip to content

Instantly share code, notes, and snippets.

@eduardoarandah
Last active March 12, 2026 23:33
Show Gist options
  • Select an option

  • Save eduardoarandah/6d618dfee89add8cbcb2720d7de76bd0 to your computer and use it in GitHub Desktop.

Select an option

Save eduardoarandah/6d618dfee89add8cbcb2720d7de76bd0 to your computer and use it in GitHub Desktop.
Shows math expression results as virtual text at the end of each line.
-- save in lua/auto_calculate.lua
-- load in your init file with `require("auto_calculate")`
-- Shows math expression results as virtual text at the end of each line.
-- Works in markdown files.
--
-- Example:
-- "2 * (20 + 1)" → displays "= 42"
-- "- 100 / 3" → displays "= 33.333333" (markdown list item)
-- "3 + cos(3)" → displays "= 2.0100075" (using sandboxed math functions)
-- "hello world" → no virtual text (not a math expression)
--
-- ## How the sandbox works
--
-- Neovim embeds LuaJIT 2.1, which is Lua 5.1 compatible but borrows the
-- load() signature from Lua 5.2 as an extension:
--
-- load(chunk, chunkname, mode, env)
--
-- The 4th parameter `env` sets the environment (available globals) for the
-- loaded chunk. In standard Lua 5.1 you'd use loadstring() + setfenv()
-- instead, but LuaJIT gives us the nicer 5.2 API.
--
-- Reference: http://luajit.org/extensions.html
-- "As an extension from Lua 5.2, the functions loadstring(), loadfile()
-- and (new) load() add an optional mode parameter."
--
-- The `mode` parameter "t" means "text only" — it refuses to load compiled
-- bytecode. This matters because crafted bytecode can crash LuaJIT.
-- The LuaJIT FAQ explicitly recommends this:
-- "Check the mode parameter for the load*() functions to disable loading
-- of bytecode."
-- Reference: https://luajit.org/faq.html
--
-- ## Security model
--
-- Previous version used a character whitelist [%d%+%-%*/%.%(%)\t ] as the
-- security boundary — anything not in that set was rejected before reaching
-- load(). This was safe but restrictive: no function calls, no variables.
--
-- Now the sandbox environment IS the security boundary. Any global name that
-- isn't explicitly in sandbox_env resolves to nil, so the expression errors
-- out (caught by pcall). This means:
-- cos(3) → works (cos is in the sandbox)
-- os.execute("rm /") → fails (os is nil in the sandbox)
-- vim.cmd("quit") → fails (vim is nil in the sandbox)
-- require("io") → fails (require is nil in the sandbox)
--
-- The sanity-check patterns below are NOT security boundaries — they're just
-- noise filters to avoid running load() on every line of prose. Even if a
-- weird string slips past them, the sandbox catches it.
--
-- ## Extending the sandbox
--
-- To add more functions, just add them to sandbox_env below. For example:
-- sandbox_env.rad = math.rad
-- sandbox_env.deg = math.deg
-- sandbox_env.log10 = math.log10
--
-- You could even expose custom functions:
-- sandbox_env.usd_to_mxn = function(x) return x * 17.5 end
-- Then in markdown: "usd_to_mxn(100)" → "= 1750"
--
-- ## LLM prompt notes (for future me)
--
-- If asking an LLM to modify this, useful context to provide:
-- - "Neovim uses LuaJIT 2.1 (Lua 5.1 compat, with 5.2 load() extension)"
-- - "load(chunk, name, 't', env) — 't' = text-only, env = sandboxed globals"
-- - "In Lua 5.1 without LuaJIT you'd need loadstring() + setfenv() instead"
-- - "The sandbox env table is the security boundary, not pattern matching"
-- - Link to http://lua-users.org/wiki/SandBoxes for the canonical pattern
local ns = vim.api.nvim_create_namespace("calc-virtual-text")
-- Sandbox environment: only math functions are available as globals.
-- No I/O, no os, no vim, no require, no debug, no dofile, no loadfile.
-- This table is reused across calls (it's never modified by the loaded chunk
-- because load() with an env makes it the chunk's _ENV / fenv, and our
-- chunks only do "return <expr>" which doesn't assign globals).
local sandbox_env = {
-- constants
pi = math.pi,
huge = math.huge,
inf = math.huge,
-- arithmetic
abs = math.abs,
ceil = math.ceil,
floor = math.floor,
max = math.max,
min = math.min,
pow = math.pow, -- LuaJIT has math.pow; also available as ^ operator
sqrt = math.sqrt,
fmod = math.fmod,
-- trigonometry
sin = math.sin,
cos = math.cos,
tan = math.tan,
asin = math.asin,
acos = math.acos,
atan = math.atan,
atan2 = math.atan2,
rad = math.rad,
deg = math.deg,
-- exponential / logarithmic
exp = math.exp,
log = math.log,
log10 = math.log10,
-- also expose the whole math table so "math.sin(x)" works too
math = math,
}
--- Safely evaluate a math expression using load() with a sandboxed environment.
--- Uses Lua's native float arithmetic so 10/3 = 3.3333... (not integer division).
---@param expr string Raw line text from the buffer
---@return string|nil result Evaluated result, or nil if not a valid expression
local function safe_eval(expr)
if not expr or expr == "" then
return nil
end
-- Strip markdown list prefixes: "- " or "* "
local cleaned = expr:gsub("^%s*[%-*]%s+", "")
-- Remove trailing "= ..." so re-evaluation works on "2 + 2 = 4"
cleaned = cleaned:gsub("%s*=.*$", "")
cleaned = vim.trim(cleaned)
if cleaned == "" then
return nil
end
---------------------------------------------------------------------------
-- Sanity checks (noise filters, NOT security boundaries)
---------------------------------------------------------------------------
-- These patterns reject obvious non-math lines early to avoid calling
-- load() on every line of prose. The sandbox catches anything dangerous
-- even if these checks miss something.
-- Must contain at least one math operator (+, -, *, /, ^, %) or a
-- function-call paren like "cos(" to look like a formula.
-- Note: we check for "(" preceded by a letter to catch function calls,
-- but also check standalone operators for plain arithmetic.
if
not cleaned:match("[%+%*/%%^]") -- has an operator (not - which is ambiguous)
and not cleaned:match("%d%s*%-") -- has subtraction after a digit
and not cleaned:match("%a%(") -- has a function call like cos(
and not cleaned:match("%d%s*/%s*[%d%(]") -- has division
then
return nil
end
-- Reject lines that look like prose: if more than half the characters are
-- letters (and it's longer than a short expression), it's probably text.
-- This catches things like "the cost is 100 + tax" where "the", "cost",
-- "is", "tax" are noise. Short lines like "cos(3)" pass fine.
if #cleaned > 10 then
local letter_count = select(2, cleaned:gsub("%a", ""))
if letter_count / #cleaned > 0.5 then
return nil
end
end
---------------------------------------------------------------------------
-- Evaluate in sandbox
---------------------------------------------------------------------------
-- load() compiles a Lua chunk; "return ..." makes it an expression.
-- LuaJIT numbers are doubles (IEEE 754), so 10/3 = 3.3333... not 3.
--
-- Arguments to load():
-- 1. chunk: the code string to compile
-- 2. chunkname: name for error messages (purely cosmetic)
-- 3. mode: "t" = text only, refuses bytecode (security)
-- 4. env: the globals table — our sandbox
local fn = load("return " .. cleaned, "=expr", "t", sandbox_env)
if not fn then
return nil
end
local ok, result = pcall(fn)
if not ok or result == nil then
return nil
end
-- Reject non-number results (e.g. if someone typed a bare string somehow)
if type(result) ~= "number" then
return nil
end
-- Reject NaN (NaN ~= NaN is the canonical check in IEEE 754)
if result ~= result then
return nil
end
-- Format result: clean up float representation
local str = tostring(result)
if str:match("%.") then
-- Strip trailing zeros: "4.00" → "4", "3.50" → "3.5"
str = str:gsub("0+$", ""):gsub("%.$", "")
end
return str
end
--- Render virtual text with evaluation results for every line in the buffer.
---@param bufnr number Buffer handle
local function update_virtual_text(bufnr)
vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1)
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
for i, line in ipairs(lines) do
local result = safe_eval(line)
if result then
vim.api.nvim_buf_set_extmark(bufnr, ns, i - 1, 0, {
virt_text = { { " = " .. result, "Comment" } },
virt_text_pos = "eol",
})
end
end
end
-- Attach to markdown buffers and re-evaluate on every text change
vim.api.nvim_create_autocmd("FileType", {
pattern = "markdown",
callback = function(ev)
local bufnr = ev.buf
update_virtual_text(bufnr)
vim.api.nvim_create_autocmd({ "TextChanged", "TextChangedI" }, {
buffer = bufnr,
callback = function()
update_virtual_text(bufnr)
end,
})
end,
})
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment