Files
codetyper.nvim/lua/codetyper/autocmds.lua
Carlos Gutierrez 60577f8951 feat: add conflict resolution, linter validation, and SEARCH/REPLACE system
- Add git-style conflict resolution with visual diff highlighting
- Add buffer-local keymaps: co/ct/cb/cn for conflict resolution
- Add floating menu with auto-show after code injection
- Add linter validation that auto-checks LSP diagnostics after accepting code
- Add SEARCH/REPLACE block parsing with fuzzy matching
- Add new commands: CoderConflictMenu, CoderLintCheck, CoderLintFix
- Update README with complete keymaps reference and issue reporting guide
- Update CHANGELOG and llms.txt with full documentation
- Clean up code comments and documentation

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-16 09:00:35 -05:00

1526 lines
46 KiB
Lua

---@mod codetyper.autocmds Autocommands for Codetyper.nvim
local M = {}
local utils = require("codetyper.utils")
--- Autocommand group name
local AUGROUP = "Codetyper"
--- Debounce timer for tree updates
local tree_update_timer = nil
local TREE_UPDATE_DEBOUNCE_MS = 1000 -- 1 second debounce
--- Track processed prompts to avoid re-processing
---@type table<string, boolean>
local processed_prompts = {}
--- Track if we're currently asking for preferences
local asking_preference = false
--- Track if we're currently processing prompts (busy flag)
local is_processing = false
--- Track the previous mode for visual mode detection
local previous_mode = "n"
--- Debounce timer for prompt processing
local prompt_process_timer = nil
local PROMPT_PROCESS_DEBOUNCE_MS = 200 -- Wait 200ms after mode change before processing
--- Generate a unique key for a prompt
---@param bufnr number Buffer number
---@param prompt table Prompt object
---@return string Unique key
local function get_prompt_key(bufnr, prompt)
return string.format("%d:%d:%d:%s", bufnr, prompt.start_line, prompt.end_line, prompt.content:sub(1, 50))
end
--- Schedule tree update with debounce
local function schedule_tree_update()
if tree_update_timer then
tree_update_timer:stop()
end
tree_update_timer = vim.defer_fn(function()
local tree = require("codetyper.tree")
tree.update_tree_log()
tree_update_timer = nil
end, TREE_UPDATE_DEBOUNCE_MS)
end
--- Setup autocommands
function M.setup()
local group = vim.api.nvim_create_augroup(AUGROUP, { clear = true })
-- Auto-check for closed prompts when leaving insert mode (works on ALL files)
vim.api.nvim_create_autocmd("InsertLeave", {
group = group,
pattern = "*",
callback = function()
-- Skip special buffers
local buftype = vim.bo.buftype
if buftype ~= "" then
return
end
-- Auto-save coder files only
local filepath = vim.fn.expand("%:p")
if utils.is_coder_file(filepath) and vim.bo.modified then
vim.cmd("silent! write")
end
-- Check for closed prompts and auto-process (respects preferences)
M.check_for_closed_prompt_with_preference()
end,
desc = "Check for closed prompt tags on InsertLeave",
})
-- Track mode changes for visual mode detection
vim.api.nvim_create_autocmd("ModeChanged", {
group = group,
pattern = "*",
callback = function(ev)
-- Extract old mode from pattern (format: "old_mode:new_mode")
local old_mode = ev.match:match("^(.-):")
if old_mode then
previous_mode = old_mode
end
end,
desc = "Track previous mode for visual mode detection",
})
-- Auto-process prompts when entering normal mode (works on ALL files)
vim.api.nvim_create_autocmd("ModeChanged", {
group = group,
pattern = "*:n",
callback = function()
-- Skip special buffers
local buftype = vim.bo.buftype
if buftype ~= "" then
return
end
-- Skip if currently processing (avoid concurrent processing)
if is_processing then
return
end
-- Skip if coming from visual mode (v, V, CTRL-V) - user is still editing
if previous_mode == "v" or previous_mode == "V" or previous_mode == "\22" then
return
end
-- Cancel any pending processing timer
if prompt_process_timer then
prompt_process_timer:stop()
prompt_process_timer = nil
end
-- Debounced processing - wait for user to truly be idle
prompt_process_timer = vim.defer_fn(function()
prompt_process_timer = nil
-- Double-check we're still in normal mode
local mode = vim.api.nvim_get_mode().mode
if mode ~= "n" then
return
end
M.check_all_prompts_with_preference()
end, PROMPT_PROCESS_DEBOUNCE_MS)
end,
desc = "Auto-process closed prompts when entering normal mode",
})
-- Also check on CursorHold as backup (works on ALL files)
vim.api.nvim_create_autocmd("CursorHold", {
group = group,
pattern = "*",
callback = function()
-- Skip special buffers
local buftype = vim.bo.buftype
if buftype ~= "" then
return
end
-- Skip if currently processing
if is_processing then
return
end
local mode = vim.api.nvim_get_mode().mode
if mode == "n" then
M.check_all_prompts_with_preference()
end
end,
desc = "Auto-process closed prompts when idle in normal mode",
})
-- Auto-set filetype for coder files based on extension
vim.api.nvim_create_autocmd({ "BufRead", "BufNewFile" }, {
group = group,
pattern = "*.coder.*",
callback = function()
M.set_coder_filetype()
end,
desc = "Set filetype for coder files",
})
-- Auto-open split view when opening a coder file directly (e.g., from nvim-tree)
vim.api.nvim_create_autocmd("BufEnter", {
group = group,
pattern = "*.coder.*",
callback = function()
-- Delay slightly to ensure buffer is fully loaded
vim.defer_fn(function()
M.auto_open_target_file()
end, 50)
end,
desc = "Auto-open target file when coder file is opened",
})
-- Cleanup on buffer close
vim.api.nvim_create_autocmd("BufWipeout", {
group = group,
pattern = "*.coder.*",
callback = function(ev)
local window = require("codetyper.window")
if window.is_open() then
window.close_split()
end
-- Clear processed prompts for this buffer
local bufnr = ev.buf
for key, _ in pairs(processed_prompts) do
if key:match("^" .. bufnr .. ":") then
processed_prompts[key] = nil
end
end
-- Clear auto-opened tracking
M.clear_auto_opened(bufnr)
end,
desc = "Cleanup on coder buffer close",
})
-- Update tree.log when files are created/written
vim.api.nvim_create_autocmd({ "BufWritePost", "BufNewFile" }, {
group = group,
pattern = "*",
callback = function(ev)
-- Skip coder files and tree.log itself
local filepath = ev.file or vim.fn.expand("%:p")
if filepath:match("%.coder%.") or filepath:match("tree%.log$") then
return
end
-- Skip non-project files
if filepath:match("node_modules") or filepath:match("%.git/") or filepath:match("%.coder/") then
return
end
-- Schedule tree update with debounce
schedule_tree_update()
-- Trigger incremental indexing if enabled
local ok_indexer, indexer = pcall(require, "codetyper.indexer")
if ok_indexer then
indexer.schedule_index_file(filepath)
end
-- Update brain with file patterns
local ok_brain, brain = pcall(require, "codetyper.brain")
if ok_brain and brain.is_initialized and brain.is_initialized() then
vim.defer_fn(function()
M.update_brain_from_file(filepath)
end, 500) -- Debounce brain updates
end
end,
desc = "Update tree.log, index, and brain on file creation/save",
})
-- Update tree.log when files are deleted (via netrw or file explorer)
vim.api.nvim_create_autocmd("BufDelete", {
group = group,
pattern = "*",
callback = function(ev)
local filepath = ev.file or ""
-- Skip special buffers and coder files
if filepath == "" or filepath:match("%.coder%.") or filepath:match("tree%.log$") then
return
end
schedule_tree_update()
end,
desc = "Update tree.log on file deletion",
})
-- Update tree on directory change
vim.api.nvim_create_autocmd("DirChanged", {
group = group,
pattern = "*",
callback = function()
schedule_tree_update()
end,
desc = "Update tree.log on directory change",
})
-- Shutdown brain on Vim exit
vim.api.nvim_create_autocmd("VimLeavePre", {
group = group,
pattern = "*",
callback = function()
local ok, brain = pcall(require, "codetyper.brain")
if ok and brain.is_initialized and brain.is_initialized() then
brain.shutdown()
end
end,
desc = "Shutdown brain and flush pending changes",
})
-- Auto-index: Create/open coder companion file when opening source files
vim.api.nvim_create_autocmd("BufEnter", {
group = group,
pattern = "*",
callback = function(ev)
-- Delay to ensure buffer is fully loaded
vim.defer_fn(function()
M.auto_index_file(ev.buf)
end, 100)
end,
desc = "Auto-index source files with coder companion",
})
end
--- Get config with fallback defaults
local function get_config_safe()
local codetyper = require("codetyper")
local config = codetyper.get_config()
-- Return defaults if not initialized
if not config or not config.patterns then
return {
patterns = {
open_tag = "/@",
close_tag = "@/",
file_pattern = "*.coder.*",
},
}
end
return config
end
--- Read attached files from prompt content
---@param prompt_content string Prompt content
---@param base_path string Base path to resolve relative file paths
---@return table[] attached_files List of {path, content} tables
local function read_attached_files(prompt_content, base_path)
local parser = require("codetyper.parser")
local file_refs = parser.extract_file_references(prompt_content)
local attached = {}
local cwd = vim.fn.getcwd()
local base_dir = vim.fn.fnamemodify(base_path, ":h")
for _, ref in ipairs(file_refs) do
local file_path = nil
-- Try resolving relative to cwd first
local cwd_path = cwd .. "/" .. ref
if utils.file_exists(cwd_path) then
file_path = cwd_path
else
-- Try resolving relative to base file directory
local rel_path = base_dir .. "/" .. ref
if utils.file_exists(rel_path) then
file_path = rel_path
end
end
if file_path then
local content = utils.read_file(file_path)
if content then
table.insert(attached, {
path = ref,
full_path = file_path,
content = content,
})
end
end
end
return attached
end
--- Check if the buffer has a newly closed prompt and auto-process (works on ANY file)
function M.check_for_closed_prompt()
-- Skip if already processing
if is_processing then
return
end
is_processing = true
local config = get_config_safe()
local parser = require("codetyper.parser")
local bufnr = vim.api.nvim_get_current_buf()
local current_file = vim.fn.expand("%:p")
-- Skip if no file
if current_file == "" then
is_processing = false
return
end
-- Get current line
local cursor = vim.api.nvim_win_get_cursor(0)
local line = cursor[1]
local lines = vim.api.nvim_buf_get_lines(bufnr, line - 1, line, false)
if #lines == 0 then
is_processing = false
return
end
local current_line = lines[1]
-- Check if line contains closing tag
if parser.has_closing_tag(current_line, config.patterns.close_tag) then
-- Find the complete prompt
local prompt = parser.get_last_prompt(bufnr)
if prompt and prompt.content and prompt.content ~= "" then
-- Generate unique key for this prompt
local prompt_key = get_prompt_key(bufnr, prompt)
-- Check if already processed
if processed_prompts[prompt_key] then
is_processing = false
return
end
-- Mark as processed
processed_prompts[prompt_key] = true
-- Check if scheduler is enabled
local codetyper = require("codetyper")
local ct_config = codetyper.get_config()
local scheduler_enabled = ct_config and ct_config.scheduler and ct_config.scheduler.enabled
if scheduler_enabled then
-- Event-driven: emit to queue
vim.schedule(function()
local queue = require("codetyper.agent.queue")
local patch_mod = require("codetyper.agent.patch")
local intent_mod = require("codetyper.agent.intent")
local scope_mod = require("codetyper.agent.scope")
local logs_panel = require("codetyper.logs_panel")
-- Open logs panel to show progress
logs_panel.ensure_open()
-- Take buffer snapshot
local snapshot = patch_mod.snapshot_buffer(bufnr, {
start_line = prompt.start_line,
end_line = prompt.end_line,
})
-- Get target path - for coder files, get the target; for regular files, use self
local target_path
if utils.is_coder_file(current_file) then
target_path = utils.get_target_path(current_file)
else
target_path = current_file
end
-- Read attached files before cleaning
local attached_files = read_attached_files(prompt.content, current_file)
-- Clean prompt content (strip file references)
local cleaned = parser.clean_prompt(parser.strip_file_references(prompt.content))
-- Check if we're working from a coder file
local is_from_coder_file = utils.is_coder_file(current_file)
-- Resolve scope in target file FIRST (need it to adjust intent)
-- Only resolve scope if NOT from coder file (line numbers don't apply)
local target_bufnr = vim.fn.bufnr(target_path)
local scope = nil
local scope_text = nil
local scope_range = nil
if not is_from_coder_file then
-- Prompt is in the actual source file, use line position for scope
if target_bufnr == -1 then
target_bufnr = bufnr
end
scope = scope_mod.resolve_scope(target_bufnr, prompt.start_line, 1)
if scope and scope.type ~= "file" then
scope_text = scope.text
scope_range = {
start_line = scope.range.start_row,
end_line = scope.range.end_row,
}
end
else
-- Prompt is in coder file - load target if needed, but don't use scope
-- Code from coder files should append to target by default
if target_bufnr == -1 then
target_bufnr = vim.fn.bufadd(target_path)
if target_bufnr ~= 0 then
vim.fn.bufload(target_bufnr)
end
end
end
-- Detect intent from prompt
local intent = intent_mod.detect(cleaned)
-- IMPORTANT: If prompt is inside a function/method and intent is "add",
-- override to "complete" since we're completing the function body
-- But NOT for coder files - they should use "add/append" by default
if not is_from_coder_file and scope and (scope.type == "function" or scope.type == "method") then
if intent.type == "add" or intent.action == "insert" or intent.action == "append" then
-- Override to complete the function instead of adding new code
intent = {
type = "complete",
scope_hint = "function",
confidence = intent.confidence,
action = "replace",
keywords = intent.keywords,
}
end
end
-- For coder files, default to "add" with "append" action
if is_from_coder_file and (intent.action == "replace" or intent.type == "complete") then
intent = {
type = intent.type == "complete" and "add" or intent.type,
confidence = intent.confidence,
action = "append",
keywords = intent.keywords,
}
end
-- Determine priority based on intent
local priority = 2 -- Normal
if intent.type == "fix" or intent.type == "complete" then
priority = 1 -- High priority for fixes and completions
elseif intent.type == "test" or intent.type == "document" then
priority = 3 -- Lower priority for tests and docs
end
-- Enqueue the event
queue.enqueue({
id = queue.generate_id(),
bufnr = bufnr,
range = { start_line = prompt.start_line, end_line = prompt.end_line },
timestamp = os.clock(),
changedtick = snapshot.changedtick,
content_hash = snapshot.content_hash,
prompt_content = cleaned,
target_path = target_path,
priority = priority,
status = "pending",
attempt_count = 0,
intent = intent,
scope = scope,
scope_text = scope_text,
scope_range = scope_range,
attached_files = attached_files,
})
local scope_info = scope
and scope.type ~= "file"
and string.format(" [%s: %s]", scope.type, scope.name or "anonymous")
or ""
utils.notify(string.format("Prompt queued: %s%s", intent.type, scope_info), vim.log.levels.INFO)
end)
else
-- Legacy: direct processing
utils.notify("Processing prompt...", vim.log.levels.INFO)
vim.schedule(function()
vim.cmd("CoderProcess")
end)
end
end
end
is_processing = false
end
--- Process a single prompt through the scheduler
--- This is the core processing logic used by both automatic and manual modes
---@param bufnr number Buffer number
---@param prompt table Prompt object with start_line, end_line, content
---@param current_file string Current file path
---@param skip_processed_check? boolean Skip the processed check (for manual mode)
function M.process_single_prompt(bufnr, prompt, current_file, skip_processed_check)
local parser = require("codetyper.parser")
local scheduler = require("codetyper.agent.scheduler")
if not prompt.content or prompt.content == "" then
return
end
-- Ensure scheduler is running
if not scheduler.status().running then
scheduler.start()
end
-- Generate unique key for this prompt
local prompt_key = get_prompt_key(bufnr, prompt)
-- Skip if already processed (unless overridden for manual mode)
if not skip_processed_check and processed_prompts[prompt_key] then
return
end
-- Mark as processed
processed_prompts[prompt_key] = true
-- Process this prompt
vim.schedule(function()
local queue = require("codetyper.agent.queue")
local patch_mod = require("codetyper.agent.patch")
local intent_mod = require("codetyper.agent.intent")
local scope_mod = require("codetyper.agent.scope")
local logs_panel = require("codetyper.logs_panel")
-- Open logs panel to show progress
logs_panel.ensure_open()
-- Take buffer snapshot
local snapshot = patch_mod.snapshot_buffer(bufnr, {
start_line = prompt.start_line,
end_line = prompt.end_line,
})
-- Get target path - for coder files, get the target; for regular files, use self
local target_path
local is_from_coder_file = utils.is_coder_file(current_file)
if is_from_coder_file then
target_path = utils.get_target_path(current_file)
else
target_path = current_file
end
-- Read attached files before cleaning
local attached_files = read_attached_files(prompt.content, current_file)
-- Clean prompt content (strip file references)
local cleaned = parser.clean_prompt(parser.strip_file_references(prompt.content))
-- Resolve scope in target file FIRST (need it to adjust intent)
-- Only resolve scope if NOT from coder file (line numbers don't apply)
local target_bufnr = vim.fn.bufnr(target_path)
local scope = nil
local scope_text = nil
local scope_range = nil
if not is_from_coder_file then
-- Prompt is in the actual source file, use line position for scope
if target_bufnr == -1 then
target_bufnr = bufnr
end
scope = scope_mod.resolve_scope(target_bufnr, prompt.start_line, 1)
if scope and scope.type ~= "file" then
scope_text = scope.text
scope_range = {
start_line = scope.range.start_row,
end_line = scope.range.end_row,
}
end
else
-- Prompt is in coder file - load target if needed
if target_bufnr == -1 then
target_bufnr = vim.fn.bufadd(target_path)
if target_bufnr ~= 0 then
vim.fn.bufload(target_bufnr)
end
end
end
-- Detect intent from prompt
local intent = intent_mod.detect(cleaned)
-- IMPORTANT: If prompt is inside a function/method and intent is "add",
-- override to "complete" since we're completing the function body
-- But NOT for coder files - they should use "add/append" by default
if not is_from_coder_file and scope and (scope.type == "function" or scope.type == "method") then
if intent.type == "add" or intent.action == "insert" or intent.action == "append" then
-- Override to complete the function instead of adding new code
intent = {
type = "complete",
scope_hint = "function",
confidence = intent.confidence,
action = "replace",
keywords = intent.keywords,
}
end
end
-- For coder files, default to "add" with "append" action
if is_from_coder_file and (intent.action == "replace" or intent.type == "complete") then
intent = {
type = intent.type == "complete" and "add" or intent.type,
confidence = intent.confidence,
action = "append",
keywords = intent.keywords,
}
end
-- Determine priority based on intent
local priority = 2
if intent.type == "fix" or intent.type == "complete" then
priority = 1
elseif intent.type == "test" or intent.type == "document" then
priority = 3
end
-- Enqueue the event
queue.enqueue({
id = queue.generate_id(),
bufnr = bufnr,
range = { start_line = prompt.start_line, end_line = prompt.end_line },
timestamp = os.clock(),
changedtick = snapshot.changedtick,
content_hash = snapshot.content_hash,
prompt_content = cleaned,
target_path = target_path,
priority = priority,
status = "pending",
attempt_count = 0,
intent = intent,
scope = scope,
scope_text = scope_text,
scope_range = scope_range,
attached_files = attached_files,
})
local scope_info = scope
and scope.type ~= "file"
and string.format(" [%s: %s]", scope.type, scope.name or "anonymous")
or ""
utils.notify(string.format("Prompt queued: %s%s", intent.type, scope_info), vim.log.levels.INFO)
end)
end
--- Check and process all closed prompts in the buffer (works on ANY file)
function M.check_all_prompts()
local parser = require("codetyper.parser")
local bufnr = vim.api.nvim_get_current_buf()
local current_file = vim.fn.expand("%:p")
-- Skip if no file
if current_file == "" then
return
end
-- Find all prompts in buffer
local prompts = parser.find_prompts_in_buffer(bufnr)
if #prompts == 0 then
return
end
-- Check if scheduler is enabled
local codetyper = require("codetyper")
local ct_config = codetyper.get_config()
local scheduler_enabled = ct_config and ct_config.scheduler and ct_config.scheduler.enabled
if not scheduler_enabled then
return
end
for _, prompt in ipairs(prompts) do
M.process_single_prompt(bufnr, prompt, current_file)
end
end
--- Check for closed prompt with preference check
--- If user hasn't chosen auto/manual mode, ask them first
function M.check_for_closed_prompt_with_preference()
local preferences = require("codetyper.preferences")
local parser = require("codetyper.parser")
-- First check if there are any prompts to process
local bufnr = vim.api.nvim_get_current_buf()
local prompts = parser.find_prompts_in_buffer(bufnr)
if #prompts == 0 then
return
end
-- Check user preference
local auto_process = preferences.is_auto_process_enabled()
if auto_process == nil then
-- Not yet decided - ask the user (but only once per session)
if not asking_preference then
asking_preference = true
preferences.ask_auto_process_preference(function(enabled)
asking_preference = false
if enabled then
-- User chose automatic - process now
M.check_for_closed_prompt()
else
-- User chose manual - show hint
utils.notify("Use :CoderProcess to process prompt tags manually", vim.log.levels.INFO)
end
end)
end
return
end
if auto_process then
-- Automatic mode - process prompts
M.check_for_closed_prompt()
end
-- Manual mode - do nothing, user will run :CoderProcess
end
--- Check all prompts with preference check
function M.check_all_prompts_with_preference()
local preferences = require("codetyper.preferences")
local parser = require("codetyper.parser")
-- First check if there are any prompts to process
local bufnr = vim.api.nvim_get_current_buf()
local prompts = parser.find_prompts_in_buffer(bufnr)
if #prompts == 0 then
return
end
-- Check if any prompts are unprocessed
local has_unprocessed = false
for _, prompt in ipairs(prompts) do
local prompt_key = get_prompt_key(bufnr, prompt)
if not processed_prompts[prompt_key] then
has_unprocessed = true
break
end
end
if not has_unprocessed then
return
end
-- Check user preference
local auto_process = preferences.is_auto_process_enabled()
if auto_process == nil then
-- Not yet decided - ask the user (but only once per session)
if not asking_preference then
asking_preference = true
preferences.ask_auto_process_preference(function(enabled)
asking_preference = false
if enabled then
-- User chose automatic - process now
M.check_all_prompts()
else
-- User chose manual - show hint
utils.notify("Use :CoderProcess to process prompt tags manually", vim.log.levels.INFO)
end
end)
end
return
end
if auto_process then
-- Automatic mode - process prompts
M.check_all_prompts()
end
-- Manual mode - do nothing, user will run :CoderProcess
end
--- Reset processed prompts for a buffer (useful for re-processing)
---@param bufnr? number Buffer number (default: current)
---@param silent? boolean Suppress notification (default: false)
function M.reset_processed(bufnr, silent)
bufnr = bufnr or vim.api.nvim_get_current_buf()
for key, _ in pairs(processed_prompts) do
if key:match("^" .. bufnr .. ":") then
processed_prompts[key] = nil
end
end
if not silent then
utils.notify("Prompt history cleared - prompts can be re-processed")
end
end
--- Track if we already opened the split for this buffer
---@type table<number, boolean>
local auto_opened_buffers = {}
--- Auto-open target file when a coder file is opened directly
function M.auto_open_target_file()
local window = require("codetyper.window")
-- Skip if split is already open
if window.is_open() then
return
end
local bufnr = vim.api.nvim_get_current_buf()
-- Skip if we already handled this buffer
if auto_opened_buffers[bufnr] then
return
end
local current_file = vim.fn.expand("%:p")
-- Skip empty paths
if not current_file or current_file == "" then
return
end
-- Verify it's a coder file
if not utils.is_coder_file(current_file) then
return
end
-- Skip if we're in a special buffer (nvim-tree, etc.)
local buftype = vim.bo[bufnr].buftype
if buftype ~= "" then
return
end
-- Mark as handled
auto_opened_buffers[bufnr] = true
-- Get the target file path
local target_path = utils.get_target_path(current_file)
-- Check if target file exists
if not utils.file_exists(target_path) then
utils.notify("Target file not found: " .. vim.fn.fnamemodify(target_path, ":t"), vim.log.levels.WARN)
return
end
-- Get config with fallback defaults
local codetyper = require("codetyper")
local config = codetyper.get_config()
-- Fallback width if config not fully loaded (percentage, e.g., 25 = 25%)
local width_pct = (config and config.window and config.window.width) or 25
local width = math.ceil(vim.o.columns * (width_pct / 100))
-- Store current coder window
local coder_win = vim.api.nvim_get_current_win()
local coder_buf = bufnr
-- Open target file in a vertical split on the right
local ok, err = pcall(function()
vim.cmd("vsplit " .. vim.fn.fnameescape(target_path))
end)
if not ok then
utils.notify("Failed to open target file: " .. tostring(err), vim.log.levels.ERROR)
auto_opened_buffers[bufnr] = nil -- Allow retry
return
end
-- Now we're in the target window (right side)
local target_win = vim.api.nvim_get_current_win()
local target_buf = vim.api.nvim_get_current_buf()
-- Set the coder window width (left side)
pcall(vim.api.nvim_win_set_width, coder_win, width)
-- Update window module state
window._coder_win = coder_win
window._coder_buf = coder_buf
window._target_win = target_win
window._target_buf = target_buf
-- Set up window options for coder window
pcall(function()
vim.wo[coder_win].number = true
vim.wo[coder_win].relativenumber = true
vim.wo[coder_win].signcolumn = "yes"
end)
utils.notify("Opened target: " .. vim.fn.fnamemodify(target_path, ":t"))
end
--- Clear auto-opened tracking for a buffer
---@param bufnr number Buffer number
function M.clear_auto_opened(bufnr)
auto_opened_buffers[bufnr] = nil
end
--- Set appropriate filetype for coder files
function M.set_coder_filetype()
local filepath = vim.fn.expand("%:p")
-- Extract the actual extension (e.g., index.coder.ts -> ts)
local ext = filepath:match("%.coder%.(%w+)$")
if ext then
-- Map extension to filetype
local ft_map = {
ts = "typescript",
tsx = "typescriptreact",
js = "javascript",
jsx = "javascriptreact",
py = "python",
lua = "lua",
go = "go",
rs = "rust",
rb = "ruby",
java = "java",
c = "c",
cpp = "cpp",
cs = "cs",
json = "json",
yaml = "yaml",
yml = "yaml",
md = "markdown",
html = "html",
css = "css",
scss = "scss",
vue = "vue",
svelte = "svelte",
}
local filetype = ft_map[ext] or ext
vim.bo.filetype = filetype
end
end
--- Clear all autocommands
function M.clear()
vim.api.nvim_del_augroup_by_name(AUGROUP)
end
--- Debounce timers for brain updates per file
---@type table<string, uv_timer_t>
local brain_update_timers = {}
--- Update brain with patterns from a file
---@param filepath string
function M.update_brain_from_file(filepath)
local ok_brain, brain = pcall(require, "codetyper.brain")
if not ok_brain or not brain.is_initialized() then
return
end
-- Read file content
local content = utils.read_file(filepath)
if not content or content == "" then
return
end
local ext = vim.fn.fnamemodify(filepath, ":e")
local lines = vim.split(content, "\n")
-- Extract key patterns from the file
local functions = {}
local classes = {}
local imports = {}
for i, line in ipairs(lines) do
-- Functions
local func = line:match("^%s*function%s+([%w_:%.]+)%s*%(")
or line:match("^%s*local%s+function%s+([%w_]+)%s*%(")
or line:match("^%s*def%s+([%w_]+)%s*%(")
or line:match("^%s*func%s+([%w_]+)%s*%(")
or line:match("^%s*async%s+function%s+([%w_]+)%s*%(")
or line:match("^%s*public%s+.*%s+([%w_]+)%s*%(")
or line:match("^%s*private%s+.*%s+([%w_]+)%s*%(")
if func then
table.insert(functions, { name = func, line = i })
end
-- Classes
local class = line:match("^%s*class%s+([%w_]+)")
or line:match("^%s*public%s+class%s+([%w_]+)")
or line:match("^%s*interface%s+([%w_]+)")
or line:match("^%s*struct%s+([%w_]+)")
if class then
table.insert(classes, { name = class, line = i })
end
-- Imports
local imp = line:match("import%s+.*%s+from%s+[\"']([^\"']+)[\"']")
or line:match("require%([\"']([^\"']+)[\"']%)")
or line:match("from%s+([%w_.]+)%s+import")
if imp then
table.insert(imports, imp)
end
end
-- Only store if file has meaningful content
if #functions == 0 and #classes == 0 then
return
end
-- Build summary
local parts = {}
if #functions > 0 then
local func_names = {}
for i, f in ipairs(functions) do
if i <= 5 then
table.insert(func_names, f.name)
end
end
table.insert(parts, "functions: " .. table.concat(func_names, ", "))
end
if #classes > 0 then
local class_names = {}
for _, c in ipairs(classes) do
table.insert(class_names, c.name)
end
table.insert(parts, "classes: " .. table.concat(class_names, ", "))
end
local summary = vim.fn.fnamemodify(filepath, ":t") .. " - " .. table.concat(parts, "; ")
-- Learn this pattern - use "pattern_detected" type to match the pattern learner
brain.learn({
type = "pattern_detected",
file = filepath,
timestamp = os.time(),
data = {
name = summary,
description = #functions .. " functions, " .. #classes .. " classes",
language = ext,
symbols = vim.tbl_map(function(f) return f.name end, functions),
example = nil,
},
})
end
--- Track buffers that have been auto-indexed
---@type table<number, boolean>
local auto_indexed_buffers = {}
--- Supported file extensions for auto-indexing
local supported_extensions = {
"ts",
"tsx",
"js",
"jsx",
"py",
"lua",
"go",
"rs",
"rb",
"java",
"c",
"cpp",
"cs",
"json",
"yaml",
"yml",
"md",
"html",
"css",
"scss",
"vue",
"svelte",
"php",
"sh",
"zsh",
}
--- Check if extension is supported
---@param ext string File extension
---@return boolean
local function is_supported_extension(ext)
for _, supported in ipairs(supported_extensions) do
if ext == supported then
return true
end
end
return false
end
--- Auto-index a file by creating/opening its coder companion
---@param bufnr number Buffer number
--- Directories to ignore for coder file creation
local ignored_directories = {
".git",
".coder",
".claude",
".vscode",
".idea",
"node_modules",
"vendor",
"dist",
"build",
"target",
"__pycache__",
".cache",
".npm",
".yarn",
"coverage",
".next",
".nuxt",
".svelte-kit",
"out",
"bin",
"obj",
}
--- Files to ignore for coder file creation (exact names or patterns)
local ignored_files = {
-- Git files
".gitignore",
".gitattributes",
".gitmodules",
-- Lock files
"package-lock.json",
"yarn.lock",
"pnpm-lock.yaml",
"Cargo.lock",
"Gemfile.lock",
"poetry.lock",
"composer.lock",
-- Config files that don't need coder companions
".env",
".env.local",
".env.development",
".env.production",
".eslintrc",
".eslintrc.json",
".prettierrc",
".prettierrc.json",
".editorconfig",
".dockerignore",
"Dockerfile",
"docker-compose.yml",
"docker-compose.yaml",
".npmrc",
".yarnrc",
".nvmrc",
"tsconfig.json",
"jsconfig.json",
"babel.config.js",
"webpack.config.js",
"vite.config.js",
"rollup.config.js",
"jest.config.js",
"vitest.config.js",
".stylelintrc",
"tailwind.config.js",
"postcss.config.js",
-- Other non-code files
"README.md",
"CHANGELOG.md",
"LICENSE",
"LICENSE.md",
"CONTRIBUTING.md",
"Makefile",
"CMakeLists.txt",
}
--- Check if a file path contains an ignored directory
---@param filepath string Full file path
---@return boolean
local function is_in_ignored_directory(filepath)
for _, dir in ipairs(ignored_directories) do
-- Check for /dirname/ or /dirname at end
if filepath:match("/" .. dir .. "/") or filepath:match("/" .. dir .. "$") then
return true
end
-- Also check for dirname/ at start (relative paths)
if filepath:match("^" .. dir .. "/") then
return true
end
end
return false
end
--- Check if a file should be ignored for coder companion creation
---@param filepath string Full file path
---@return boolean
local function should_ignore_for_coder(filepath)
local filename = vim.fn.fnamemodify(filepath, ":t")
-- Check exact filename matches
for _, ignored in ipairs(ignored_files) do
if filename == ignored then
return true
end
end
-- Check if file starts with dot (hidden/config files)
if filename:match("^%.") then
return true
end
-- Check if in ignored directory
if is_in_ignored_directory(filepath) then
return true
end
return false
end
function M.auto_index_file(bufnr)
-- Skip if buffer is invalid
if not vim.api.nvim_buf_is_valid(bufnr) then
return
end
-- Skip if already indexed
if auto_indexed_buffers[bufnr] then
return
end
-- Get file path
local filepath = vim.api.nvim_buf_get_name(bufnr)
if not filepath or filepath == "" then
return
end
-- Skip coder files
if utils.is_coder_file(filepath) then
return
end
-- Skip special buffers
local buftype = vim.bo[bufnr].buftype
if buftype ~= "" then
return
end
-- Skip unsupported file types
local ext = vim.fn.fnamemodify(filepath, ":e")
if ext == "" or not is_supported_extension(ext) then
return
end
-- Skip ignored directories and files (node_modules, .git, config files, etc.)
if should_ignore_for_coder(filepath) then
return
end
-- Skip if auto_index is disabled in config
local codetyper = require("codetyper")
local config = codetyper.get_config()
if config and config.auto_index == false then
return
end
-- Mark as indexed
auto_indexed_buffers[bufnr] = true
-- Get coder companion path
local coder_path = utils.get_coder_path(filepath)
-- Check if coder file already exists
local coder_exists = utils.file_exists(coder_path)
-- Create coder file with pseudo-code context if it doesn't exist
if not coder_exists then
local filename = vim.fn.fnamemodify(filepath, ":t")
local ext = vim.fn.fnamemodify(filepath, ":e")
-- Determine comment style based on extension
local comment_prefix = "--"
local comment_block_start = "--[["
local comment_block_end = "]]"
if ext == "ts" or ext == "tsx" or ext == "js" or ext == "jsx" or ext == "java" or ext == "c" or ext == "cpp" or ext == "cs" or ext == "go" or ext == "rs" then
comment_prefix = "//"
comment_block_start = "/*"
comment_block_end = "*/"
elseif ext == "py" or ext == "rb" or ext == "yaml" or ext == "yml" then
comment_prefix = "#"
comment_block_start = '"""'
comment_block_end = '"""'
end
-- Read target file to analyze its structure
local content = ""
pcall(function()
local lines = vim.fn.readfile(filepath)
if lines then
content = table.concat(lines, "\n")
end
end)
-- Extract structure from the file
local functions = extract_functions(content, ext)
local classes = extract_classes(content, ext)
local imports = extract_imports(content, ext)
-- Build pseudo-code context
local pseudo_code = {}
-- Header
table.insert(pseudo_code, comment_prefix .. " ═══════════════════════════════════════════════════════════")
table.insert(pseudo_code, comment_prefix .. " CODER COMPANION: " .. filename)
table.insert(pseudo_code, comment_prefix .. " ═══════════════════════════════════════════════════════════")
table.insert(pseudo_code, comment_prefix .. " This file describes the business logic and behavior of " .. filename)
table.insert(pseudo_code, comment_prefix .. " Edit this pseudo-code to guide code generation.")
table.insert(pseudo_code, comment_prefix .. " Use /@ @/ tags for specific generation requests.")
table.insert(pseudo_code, comment_prefix .. "")
-- Module purpose
table.insert(pseudo_code, comment_prefix .. " ─────────────────────────────────────────────────────────────")
table.insert(pseudo_code, comment_prefix .. " MODULE PURPOSE:")
table.insert(pseudo_code, comment_prefix .. " ─────────────────────────────────────────────────────────────")
table.insert(pseudo_code, comment_prefix .. " TODO: Describe what this module/file is responsible for")
table.insert(pseudo_code, comment_prefix .. " Example: \"Handles user authentication and session management\"")
table.insert(pseudo_code, comment_prefix .. "")
-- Dependencies section
if #imports > 0 then
table.insert(pseudo_code, comment_prefix .. " ─────────────────────────────────────────────────────────────")
table.insert(pseudo_code, comment_prefix .. " DEPENDENCIES:")
table.insert(pseudo_code, comment_prefix .. " ─────────────────────────────────────────────────────────────")
for _, imp in ipairs(imports) do
table.insert(pseudo_code, comment_prefix .. "" .. imp)
end
table.insert(pseudo_code, comment_prefix .. "")
end
-- Classes section
if #classes > 0 then
table.insert(pseudo_code, comment_prefix .. " ─────────────────────────────────────────────────────────────")
table.insert(pseudo_code, comment_prefix .. " CLASSES:")
table.insert(pseudo_code, comment_prefix .. " ─────────────────────────────────────────────────────────────")
for _, class in ipairs(classes) do
table.insert(pseudo_code, comment_prefix .. "")
table.insert(pseudo_code, comment_prefix .. " class " .. class.name .. ":")
table.insert(pseudo_code, comment_prefix .. " PURPOSE: TODO - describe what this class represents")
table.insert(pseudo_code, comment_prefix .. " RESPONSIBILITIES:")
table.insert(pseudo_code, comment_prefix .. " - TODO: list main responsibilities")
end
table.insert(pseudo_code, comment_prefix .. "")
end
-- Functions section
if #functions > 0 then
table.insert(pseudo_code, comment_prefix .. " ─────────────────────────────────────────────────────────────")
table.insert(pseudo_code, comment_prefix .. " FUNCTIONS:")
table.insert(pseudo_code, comment_prefix .. " ─────────────────────────────────────────────────────────────")
for _, func in ipairs(functions) do
table.insert(pseudo_code, comment_prefix .. "")
table.insert(pseudo_code, comment_prefix .. " " .. func.name .. "():")
table.insert(pseudo_code, comment_prefix .. " PURPOSE: TODO - what does this function do?")
table.insert(pseudo_code, comment_prefix .. " INPUTS: TODO - describe parameters")
table.insert(pseudo_code, comment_prefix .. " OUTPUTS: TODO - describe return value")
table.insert(pseudo_code, comment_prefix .. " BEHAVIOR:")
table.insert(pseudo_code, comment_prefix .. " - TODO: describe step-by-step logic")
end
table.insert(pseudo_code, comment_prefix .. "")
end
-- If empty file, provide starter template
if #functions == 0 and #classes == 0 then
table.insert(pseudo_code, comment_prefix .. " ─────────────────────────────────────────────────────────────")
table.insert(pseudo_code, comment_prefix .. " PLANNED STRUCTURE:")
table.insert(pseudo_code, comment_prefix .. " ─────────────────────────────────────────────────────────────")
table.insert(pseudo_code, comment_prefix .. " TODO: Describe what you want to build in this file")
table.insert(pseudo_code, comment_prefix .. "")
table.insert(pseudo_code, comment_prefix .. " Example pseudo-code:")
table.insert(pseudo_code, comment_prefix .. " /@")
table.insert(pseudo_code, comment_prefix .. " Create a module that:")
table.insert(pseudo_code, comment_prefix .. " 1. Exports a main function")
table.insert(pseudo_code, comment_prefix .. " 2. Handles errors gracefully")
table.insert(pseudo_code, comment_prefix .. " 3. Returns structured data")
table.insert(pseudo_code, comment_prefix .. " @/")
table.insert(pseudo_code, comment_prefix .. "")
end
-- Business rules section
table.insert(pseudo_code, comment_prefix .. " ─────────────────────────────────────────────────────────────")
table.insert(pseudo_code, comment_prefix .. " BUSINESS RULES:")
table.insert(pseudo_code, comment_prefix .. " ─────────────────────────────────────────────────────────────")
table.insert(pseudo_code, comment_prefix .. " TODO: Document any business rules, constraints, or requirements")
table.insert(pseudo_code, comment_prefix .. " Example:")
table.insert(pseudo_code, comment_prefix .. " - Users must be authenticated before accessing this feature")
table.insert(pseudo_code, comment_prefix .. " - Data must be validated before saving")
table.insert(pseudo_code, comment_prefix .. " - Errors should be logged but not exposed to users")
table.insert(pseudo_code, comment_prefix .. "")
-- Footer with generation tags example
table.insert(pseudo_code, comment_prefix .. " ═══════════════════════════════════════════════════════════")
table.insert(pseudo_code, comment_prefix .. " Use /@ @/ tags below to request code generation:")
table.insert(pseudo_code, comment_prefix .. " ═══════════════════════════════════════════════════════════")
table.insert(pseudo_code, "")
utils.write_file(coder_path, table.concat(pseudo_code, "\n"))
end
-- Notify user about the coder companion
local coder_filename = vim.fn.fnamemodify(coder_path, ":t")
if coder_exists then
utils.notify("Coder companion available: " .. coder_filename, vim.log.levels.DEBUG)
else
utils.notify("Created coder companion: " .. coder_filename, vim.log.levels.INFO)
end
end
--- Open the coder companion for the current file
---@param open_split? boolean Whether to open in split view (default: true)
function M.open_coder_companion(open_split)
open_split = open_split ~= false -- Default to true
local filepath = vim.fn.expand("%:p")
if not filepath or filepath == "" then
utils.notify("No file open", vim.log.levels.WARN)
return
end
if utils.is_coder_file(filepath) then
utils.notify("Already in coder file", vim.log.levels.INFO)
return
end
local coder_path = utils.get_coder_path(filepath)
-- Create if it doesn't exist
if not utils.file_exists(coder_path) then
local filename = vim.fn.fnamemodify(filepath, ":t")
local ext = vim.fn.fnamemodify(filepath, ":e")
local comment_prefix = "--"
if vim.tbl_contains({ "js", "jsx", "ts", "tsx", "java", "c", "cpp", "cs", "go", "rs", "php" }, ext) then
comment_prefix = "//"
elseif vim.tbl_contains({ "py", "sh", "zsh", "yaml", "yml" }, ext) then
comment_prefix = "#"
elseif vim.tbl_contains({ "html", "md" }, ext) then
comment_prefix = "<!--"
end
local close_comment = comment_prefix == "<!--" and " -->" or ""
local template = string.format(
[[%s Coder companion for %s%s
%s Use /@ @/ tags to write pseudo-code prompts%s
%s Example:%s
%s /@%s
%s Add a function that validates user input%s
%s - Check for empty strings%s
%s - Validate email format%s
%s @/%s
]],
comment_prefix,
filename,
close_comment,
comment_prefix,
close_comment,
comment_prefix,
close_comment,
comment_prefix,
close_comment,
comment_prefix,
close_comment,
comment_prefix,
close_comment,
comment_prefix,
close_comment,
comment_prefix,
close_comment
)
utils.write_file(coder_path, template)
end
if open_split then
-- Use the window module to open split view
local window = require("codetyper.window")
window.open_split(coder_path, filepath)
else
-- Just open the coder file
vim.cmd("edit " .. vim.fn.fnameescape(coder_path))
end
end
--- Clear auto-indexed tracking for a buffer
---@param bufnr number Buffer number
function M.clear_auto_indexed(bufnr)
auto_indexed_buffers[bufnr] = nil
end
return M