Skip to content

StefanBartl/monkeypatch.nvim

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

MonkeyPatch.nvim

This project is highly experimental and currently in alpha phase. The main functionalities are not working yet. Expect errors and changes.

version State Lazy.nvim compatible Neovim Lua

Intelligent, multi-strategy patch system for Neovim plugins with automatic Lazy.nvim integration.

Features

  • 🎯 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

Installation

lazy.nvim

{
"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,
}

Quick Start

1. Define Patches

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,
})

2. Load Registry in init.lua

require("config.patches")  -- Load your patch definitions

3. Apply Patches

" 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 monkeypatch

Or programmatically:

-- Apply all
require("monkeypatch").apply_all_async()

-- Apply filtered
require("monkeypatch").apply_async({
  repos = { "gitsigns.nvim" },
  callback = function(results)
    print("Applied:", #results)
  end
})

Strategy Details

1. Diff Strategy

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

2. Semantic Strategy

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

3. Tree-sitter Strategy (Experimental)

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

Computing Semantic Hashes

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")

Strategy Fallback

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.


Migration from Old System

Step 1: Keep Existing Diffs

-- 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",
})

Step 2: Gradually Add Semantic Patches

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,
})

Step 3: Configure Fallback

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

Examples

Example 1: Fix Race Condition

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,
})

Example 2: Line-Ending Fix (Diff)

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.


Troubleshooting

Patch Fails: "hash_mismatch"

Cause: Function body changed upstream.

Solution:

  1. Check if fix was merged upstream:
   :MonkeyPatchLogs
  1. If not merged, recompute hash:
   local new_hash = require("monkeypatch.utils.hash").string_sha256(new_normalized_body)
  1. Update patch definition with new hash

Patch Fails: "malformed patch"

Cause: Diff has line-ending issues.

Solution:

  1. Use dos2unix on patch file:
   dos2unix ~/.config/nvim/patches/**/*.patch
  1. Or let preprocessor handle it (should work automatically)

All Strategies Fail

Check logs:

:MonkeyPatchLogs

Common causes:

  • Target file moved/renamed
  • Function renamed (semantic)
  • Surrounding context changed too much (diff)

Solution: Create new patch or disable until upstream stabilizes.


API Reference

setup(opts)

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(entry)

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_async(callback?)

Apply all enabled patches.

require("monkeypatch").apply_all_async(function(results)
  print("Applied:", #results)
end)

apply_async(opts)

Apply filtered patches.

require("monkeypatch").apply_async({
  repos = { "gitsigns.nvim" },
  keys = { "specific-key" },
  strategies = { "semantic" },
  callback = function(results)
    -- Handle results
  end
})

list()

Get all registered patches.

local patches = require("monkeypatch").list()
for _, patch in ipairs(patches) do
  print(patch.key, patch.strategy, patch.enabled)
end

get_patches(filters)

Get filtered patches.

local patches = require("monkeypatch").get_patches({
  repos = { "noice.nvim" },
  strategies = { "semantic" },
})

User Commands

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

Architecture

┌─────────────────────────────────────────────┐
│           Public API (init.lua)             │
└─────────────────┬───────────────────────────┘
                  │
       ┌──────────┴──────────┐
       │                     │
┌──────▼────────┐   ┌────────▼────────┐
│   Registry    │   │  Orchestrator   │
│  (patches)    │   │  (strategies)   │
└───────────────┘   └────────┬────────┘
                             │
              ┌──────────────┼──────────────┐
              │              │              │
       ┌──────▼─────┐ ┌─────▼─────┐ ┌─────▼──────┐
       │   Diff     │ │ Semantic  │ │Tree-sitter │
       │  Engine    │ │  Engine   │ │   Engine   │
       └────────────┘ └───────────┘ └────────────┘

Performance

  • 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)

License

MIT License


Contributing

Pull requests are welcome. For major changes, please open an issue first to discuss proposed changes.


Disclaimer

ℹ️ This plugin is under active development – some features are planned or experimental. Expect changes in upcoming releases.


Feedback

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.


About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages