This project is highly experimental and currently in alpha phase. The main functionalities are not working yet. Expect errors and changes.
Intelligent, multi-strategy patch system for Neovim plugins with automatic Lazy.nvim integration.
- 🎯 Multiple Patch Strategies
- Classic unified diffs with preprocessing
- Semantic function replacement with hash validation
- AST-based Tree-sitter transformations (optional)
- 🔄 Strategy Fallback - Automatically tries alternative strategies on failure
- ⚡ Async & Non-blocking - Parallel patch application via vim.loop
- 🎨 Lazy.nvim Integration - Auto-applies patches after plugin updates
- 📊 Status Tracking - Persistent status with checksums
- 🔍 Health Check -
:checkhealth monkeypatch - 📝 Structured Logging - JSON logs with rotation
{
"StefanBartl/monkeypatch.nvim",
config = function()
require("monkeypatch").setup({
-- Strategy execution order (first to last)
strategy_order = { "diff", "semantic" },
-- Which strategies are enabled
enabled_strategies = {
diff = true,
semantic = true,
treesitter = false, -- Experimental
},
-- Parallel patch operations
max_concurrency = 3,
-- Per-patch timeout
timeout_ms = 30000,
-- Show notifications
notify = true,
-- Debug logging
verbose = false,
-- Delay after LazyUpdate event
lazy_update_delay_ms = 1000,
-- Diff fuzz factor (0-3)
diff_fuzz_factor = 2,
-- Semantic mode: strict (reject on hash mismatch) or permissive (warn)
semantic_strict = true,
})
end,
}Create a patch registry file (e.g., lua/config/patches.lua):
local monkeypatch = require("monkeypatch")
-- Method 1: Classic Diff Patch
monkeypatch.register({
key = "gitsigns-system-compat",
repo = "gitsigns.nvim",
strategy = "diff",
enabled = true,
priority = 100,
strip = 0,
patch = vim.fn.stdpath("config") .. "/patches/gitsigns/system_compat.patch",
target = vim.fn.stdpath("data") .. "/lazy/gitsigns.nvim/lua/gitsigns/system/compat.lua",
})
-- Method 2: Semantic Function Replacement
monkeypatch.register({
key = "noice-signature-fix",
repo = "noice.nvim",
strategy = "semantic",
enabled = true,
priority = 90,
target = vim.fn.stdpath("data") .. "/lazy/noice.nvim/lua/noice/lsp/signature.lua",
function_name = "on_signature",
expected_hash = "9c1f4e3d8a7b2c5f", -- SHA256 of normalized function body
replacement = function()
return [[
local function on_signature(err, result, ctx, config)
-- Your fixed implementation
if not result or not result.signatures then
return
end
-- ... rest of function
end
]]
end,
})
-- Method 3: Tree-sitter (Optional)
monkeypatch.register({
key = "telescope-picker-fix",
repo = "telescope.nvim",
strategy = "treesitter",
enabled = true,
priority = 80,
target = vim.fn.stdpath("data") .. "/lazy/telescope.nvim/lua/telescope/pickers.lua",
query = [[
(function_definition
name: (identifier) @name
(#eq? @name "new"))
]],
replacement = function(node)
return "function M.new(...)\n -- Fixed implementation\nend"
end,
})require("config.patches") -- Load your patch definitions" Apply all patches
:MonkeyPatchApply
" Apply specific repo
:MonkeyPatchApply repo gitsigns.nvim
" Apply specific patch
:MonkeyPatchApply key gitsigns-system-compat
" List registered patches
:MonkeyPatchList
" View logs
:MonkeyPatchLogs
" Check health
:checkhealth monkeypatchOr programmatically:
-- Apply all
require("monkeypatch").apply_all_async()
-- Apply filtered
require("monkeypatch").apply_async({
repos = { "gitsigns.nvim" },
callback = function(results)
print("Applied:", #results)
end
})Classic unified diff with:
- Automatic line-ending normalization (CRLF → LF)
- Windows absolute path simplification
- Configurable fuzz factor
- Preprocessing for cross-platform compatibility
Best for:
- Simple line-based changes
- Small contextual modifications
- Existing .patch files
Limitations:
- Fails if surrounding context changes
- Sensitive to whitespace/formatting
Function-level replacement with hash validation:
- Extracts target function from source
- Computes normalized hash
- Validates against expected hash
- Replaces entire function body
Best for:
- Bug fixes in specific functions
- Robust against formatting changes
- Changes isolated to single function
Advantages:
- Survives plugin refactoring
- Clear failure modes (hash mismatch)
- Self-documenting
Limitations:
- Requires function to be extractable
- Cannot modify multiple locations
- Hash must be computed manually
AST-based transformation:
- Parses Lua with Tree-sitter
- Uses queries to locate nodes
- Replaces syntactic structures
Best for:
- Complex structural changes
- Expression-level modifications
- Ultra-robust patches
Limitations:
- Requires Tree-sitter Lua parser
- Higher complexity
- Overkill for simple fixes
To create a semantic patch, you need the expected hash:
local fs = require("monkeypatch.utils.fs")
local lua_parser = require("monkeypatch.utils.lua_parser")
local hash = require("monkeypatch.utils.hash")
-- Read target file
local content = fs.read_file("/path/to/file.lua")
-- Extract function
local func = lua_parser.extract_function(content, "function_name")
-- Get hash
print("Expected hash:", hash.string_sha256(func.normalized_body))Or use the helper command:
:lua require("monkeypatch.utils.lua_parser").extract_function(vim.api.nvim_buf_get_lines(0, 0, -1, false), "function_name")Configure execution order:
require("monkeypatch").setup({
strategy_order = { "semantic", "diff" }, -- Try semantic first, fallback to diff
})When a patch is registered with strategy = "any", the orchestrator tries all enabled strategies in order until one succeeds.
-- Old: paths.lua entry
{
key = "gitsigns-compat",
patch = "~/.config/nvim/patches/gitsigns/compat.patch",
target = "~/.local/share/nvim/lazy/gitsigns.nvim/lua/gitsigns/compat.lua",
strip = 0,
}
-- New: monkeypatch.register
monkeypatch.register({
key = "gitsigns-compat",
repo = "gitsigns.nvim",
strategy = "diff",
enabled = true,
priority = 100,
strip = 0,
patch = vim.fn.stdpath("config") .. "/patches/gitsigns/compat.patch",
target = vim.fn.stdpath("data") .. "/lazy/gitsigns.nvim/lua/gitsigns/compat.lua",
})For critical fixes, compute hash and create semantic patch:
monkeypatch.register({
key = "gitsigns-compat-semantic",
repo = "gitsigns.nvim",
strategy = "semantic",
enabled = true,
priority = 110, -- Higher priority than diff
target = vim.fn.stdpath("data") .. "/lazy/gitsigns.nvim/lua/gitsigns/compat.lua",
function_name = "system",
expected_hash = "abcdef123456",
replacement = function()
return [[
local function system(cmd, opts, on_exit)
-- Fixed implementation
end
]]
end,
})require("monkeypatch").setup({
strategy_order = { "semantic", "diff" }, -- Try semantic first
})Now:
- If function unchanged → semantic applies
- If function changed → falls back to diff
- Best of both worlds
Problem: Function has race condition in buffer validation.
Solution: Semantic patch with hash validation.
monkeypatch.register({
key = "plugin-buffer-guard",
repo = "some-plugin.nvim",
strategy = "semantic",
enabled = true,
priority = 100,
target = vim.fn.stdpath("data") .. "/lazy/some-plugin.nvim/lua/plugin/module.lua",
function_name = "on_attach",
expected_hash = "fedcba987654",
replacement = function()
return [[
local function on_attach(client, bufnr)
-- Add buffer validation
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then
return
end
-- Original logic
vim.api.nvim_buf_set_option(bufnr, "omnifunc", "v:lua.vim.lsp.omnifunc")
end
]]
end,
})Problem: Windows CRLF in diff file.
Solution: Diff strategy auto-normalizes.
monkeypatch.register({
key = "plugin-windows-fix",
repo = "plugin.nvim",
strategy = "diff",
enabled = true,
strip = 0,
patch = vim.fn.stdpath("config") .. "/patches/plugin/fix.patch",
target = vim.fn.stdpath("data") .. "/lazy/plugin.nvim/lua/plugin/file.lua",
})No manual line-ending conversion needed.
Cause: Function body changed upstream.
Solution:
- Check if fix was merged upstream:
:MonkeyPatchLogs- If not merged, recompute hash:
local new_hash = require("monkeypatch.utils.hash").string_sha256(new_normalized_body)- Update patch definition with new hash
Cause: Diff has line-ending issues.
Solution:
- Use
dos2unixon patch file:
dos2unix ~/.config/nvim/patches/**/*.patch- Or let preprocessor handle it (should work automatically)
Check logs:
:MonkeyPatchLogsCommon causes:
- Target file moved/renamed
- Function renamed (semantic)
- Surrounding context changed too much (diff)
Solution: Create new patch or disable until upstream stabilizes.
Initialize MonkeyPatch.nvim.
require("monkeypatch").setup({
strategy_order = { "diff", "semantic", "treesitter" },
enabled_strategies = { diff = true, semantic = true, treesitter = false },
max_concurrency = 3,
timeout_ms = 30000,
notify = true,
verbose = false,
lazy_update_delay_ms = 1000,
diff_fuzz_factor = 2,
semantic_strict = true,
})Register a patch.
local ok, err = require("monkeypatch").register({
key = "unique-key",
repo = "plugin.nvim",
strategy = "diff" | "semantic" | "treesitter",
enabled = true,
priority = 100,
-- Strategy-specific fields...
})Returns: boolean success, string? error
Apply all enabled patches.
require("monkeypatch").apply_all_async(function(results)
print("Applied:", #results)
end)Apply filtered patches.
require("monkeypatch").apply_async({
repos = { "gitsigns.nvim" },
keys = { "specific-key" },
strategies = { "semantic" },
callback = function(results)
-- Handle results
end
})Get all registered patches.
local patches = require("monkeypatch").list()
for _, patch in ipairs(patches) do
print(patch.key, patch.strategy, patch.enabled)
endGet filtered patches.
local patches = require("monkeypatch").get_patches({
repos = { "noice.nvim" },
strategies = { "semantic" },
})| Command | Description |
|---|---|
:MonkeyPatchApply |
Apply all patches |
:MonkeyPatchApply repo <name> |
Apply patches for specific repo |
:MonkeyPatchApply key <key> |
Apply specific patch |
:MonkeyPatchList |
List registered patches |
:MonkeyPatchLogs |
Open log file |
:checkhealth monkeypatch |
Health check |
┌─────────────────────────────────────────────┐
│ Public API (init.lua) │
└─────────────────┬───────────────────────────┘
│
┌──────────┴──────────┐
│ │
┌──────▼────────┐ ┌────────▼────────┐
│ Registry │ │ Orchestrator │
│ (patches) │ │ (strategies) │
└───────────────┘ └────────┬────────┘
│
┌──────────────┼──────────────┐
│ │ │
┌──────▼─────┐ ┌─────▼─────┐ ┌─────▼──────┐
│ Diff │ │ Semantic │ │Tree-sitter │
│ Engine │ │ Engine │ │ Engine │
└────────────┘ └───────────┘ └────────────┘
- Startup overhead: <5ms (lazy-loaded)
- Per-patch (diff): 50-200ms (async)
- Per-patch (semantic): 10-50ms (in-memory)
- Memory: <1MB for 50 patches
- Concurrency: Configurable (default: 3 parallel)
Pull requests are welcome. For major changes, please open an issue first to discuss proposed changes.
ℹ️ This plugin is under active development – some features are planned or experimental. Expect changes in upcoming releases.
Your feedback is very welcome!
Please use the GitHub issue tracker to:
- Report bugs
- Suggest new features
- Ask questions about usage
- Share thoughts on UI or functionality
For general discussion, feel free to open a GitHub Discussion.
If you find this plugin helpful, consider giving it a ⭐ on GitHub — it helps others discover the project.