Last active
March 12, 2026 23:33
-
-
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| -- 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