Patch/inject: plain-code inline, inject() API, range capture, logger

- Inline prompts: use plain 'replace selection' prompt instead of SEARCH/REPLACE
- Add codetyper.inject.inject(bufnr, code, opts) for patch apply (replace/insert/append)
- Patch: on SEARCH/REPLACE failure use REPLACE parts only; inline always replace range
- Fix 0-0 range: clamp in create_from_event, prefer scope_range when invalid
- Commands: capture injection range from selection (derive end from line count); no selection = whole file
- Commands: log injection range; pass injection_range in prompt; autocmds prefer it
- Replace diagnostic logs with codetyper.support.logger in patch and scheduler
- Fix patch apply syntax (code_to_inject variable for multi-line logger call)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-02-16 18:06:21 -05:00
parent e534d607d8
commit 4463a8144d
16 changed files with 1668 additions and 283 deletions

View File

@@ -3,17 +3,26 @@
local M = {}
local utils = require("codetyper.support.utils")
local logger = require("codetyper.support.logger")
--- Get config with safe fallback
---@return table config
local function get_config_safe()
logger.func_entry("parser", "get_config_safe", {})
local ok, codetyper = pcall(require, "codetyper")
if ok and codetyper.get_config then
local config = codetyper.get_config()
if config and config.patterns then
logger.debug("parser", "get_config_safe: loaded config from codetyper")
logger.func_exit("parser", "get_config_safe", "success")
return config
end
end
logger.debug("parser", "get_config_safe: using fallback defaults")
logger.func_exit("parser", "get_config_safe", "fallback")
-- Fallback defaults
return {
patterns = {
@@ -29,6 +38,12 @@ end
---@param close_tag string Closing tag
---@return CoderPrompt[] List of found prompts
function M.find_prompts(content, open_tag, close_tag)
logger.func_entry("parser", "find_prompts", {
content_length = #content,
open_tag = open_tag,
close_tag = close_tag
})
local prompts = {}
local escaped_open = utils.escape_pattern(open_tag)
local escaped_close = utils.escape_pattern(close_tag)
@@ -38,11 +53,14 @@ function M.find_prompts(content, open_tag, close_tag)
local current_prompt = nil
local prompt_content = {}
logger.debug("parser", "find_prompts: parsing " .. #lines .. " lines")
for line_num, line in ipairs(lines) do
if not in_prompt then
-- Look for opening tag
local start_col = line:find(escaped_open)
if start_col then
logger.debug("parser", "find_prompts: found opening tag at line " .. line_num .. ", col " .. start_col)
in_prompt = true
current_prompt = {
start_line = line_num,
@@ -58,6 +76,7 @@ function M.find_prompts(content, open_tag, close_tag)
current_prompt.end_line = line_num
current_prompt.end_col = start_col + #open_tag + end_col + #close_tag - 2
table.insert(prompts, current_prompt)
logger.debug("parser", "find_prompts: single-line prompt completed at line " .. line_num)
in_prompt = false
current_prompt = nil
else
@@ -75,6 +94,7 @@ function M.find_prompts(content, open_tag, close_tag)
current_prompt.end_line = line_num
current_prompt.end_col = end_col + #close_tag - 1
table.insert(prompts, current_prompt)
logger.debug("parser", "find_prompts: multi-line prompt completed at line " .. line_num .. ", total lines: " .. #prompt_content)
in_prompt = false
current_prompt = nil
prompt_content = {}
@@ -84,6 +104,9 @@ function M.find_prompts(content, open_tag, close_tag)
end
end
logger.debug("parser", "find_prompts: found " .. #prompts .. " prompts total")
logger.func_exit("parser", "find_prompts", "found " .. #prompts .. " prompts")
return prompts
end
@@ -91,12 +114,19 @@ end
---@param bufnr number Buffer number
---@return CoderPrompt[] List of found prompts
function M.find_prompts_in_buffer(bufnr)
logger.func_entry("parser", "find_prompts_in_buffer", { bufnr = bufnr })
local config = get_config_safe()
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
local content = table.concat(lines, "\n")
return M.find_prompts(content, config.patterns.open_tag, config.patterns.close_tag)
logger.debug("parser", "find_prompts_in_buffer: bufnr=" .. bufnr .. ", lines=" .. #lines .. ", content_length=" .. #content)
local result = M.find_prompts(content, config.patterns.open_tag, config.patterns.close_tag)
logger.func_exit("parser", "find_prompts_in_buffer", "found " .. #result .. " prompts")
return result
end
--- Get prompt at cursor position
@@ -108,21 +138,37 @@ function M.get_prompt_at_cursor(bufnr)
local line = cursor[1]
local col = cursor[2] + 1 -- Convert to 1-indexed
logger.func_entry("parser", "get_prompt_at_cursor", {
bufnr = bufnr,
line = line,
col = col
})
local prompts = M.find_prompts_in_buffer(bufnr)
for _, prompt in ipairs(prompts) do
logger.debug("parser", "get_prompt_at_cursor: checking " .. #prompts .. " prompts")
for i, prompt in ipairs(prompts) do
logger.debug("parser", "get_prompt_at_cursor: checking prompt " .. i .. " (lines " .. prompt.start_line .. "-" .. prompt.end_line .. ")")
if line >= prompt.start_line and line <= prompt.end_line then
logger.debug("parser", "get_prompt_at_cursor: cursor line " .. line .. " is within prompt line range")
if line == prompt.start_line and col < prompt.start_col then
logger.debug("parser", "get_prompt_at_cursor: cursor col " .. col .. " is before prompt start_col " .. prompt.start_col)
goto continue
end
if line == prompt.end_line and col > prompt.end_col then
logger.debug("parser", "get_prompt_at_cursor: cursor col " .. col .. " is after prompt end_col " .. prompt.end_col)
goto continue
end
logger.debug("parser", "get_prompt_at_cursor: found prompt at cursor")
logger.func_exit("parser", "get_prompt_at_cursor", "prompt found")
return prompt
end
::continue::
end
logger.debug("parser", "get_prompt_at_cursor: no prompt found at cursor")
logger.func_exit("parser", "get_prompt_at_cursor", nil)
return nil
end
@@ -131,12 +177,20 @@ end
---@return CoderPrompt|nil Last prompt or nil
function M.get_last_prompt(bufnr)
bufnr = bufnr or vim.api.nvim_get_current_buf()
logger.func_entry("parser", "get_last_prompt", { bufnr = bufnr })
local prompts = M.find_prompts_in_buffer(bufnr)
if #prompts > 0 then
return prompts[#prompts]
local last = prompts[#prompts]
logger.debug("parser", "get_last_prompt: returning prompt at line " .. last.start_line)
logger.func_exit("parser", "get_last_prompt", "prompt at line " .. last.start_line)
return last
end
logger.debug("parser", "get_last_prompt: no prompts found")
logger.func_exit("parser", "get_last_prompt", nil)
return nil
end
@@ -144,18 +198,30 @@ end
---@param content string Prompt content
---@return "refactor" | "add" | "document" | "explain" | "generic" Prompt type
function M.detect_prompt_type(content)
logger.func_entry("parser", "detect_prompt_type", { content_preview = content:sub(1, 50) })
local lower = content:lower()
if lower:match("refactor") then
logger.debug("parser", "detect_prompt_type: detected 'refactor'")
logger.func_exit("parser", "detect_prompt_type", "refactor")
return "refactor"
elseif lower:match("add") or lower:match("create") or lower:match("implement") then
logger.debug("parser", "detect_prompt_type: detected 'add'")
logger.func_exit("parser", "detect_prompt_type", "add")
return "add"
elseif lower:match("document") or lower:match("comment") or lower:match("jsdoc") then
logger.debug("parser", "detect_prompt_type: detected 'document'")
logger.func_exit("parser", "detect_prompt_type", "document")
return "document"
elseif lower:match("explain") or lower:match("what") or lower:match("how") then
logger.debug("parser", "detect_prompt_type: detected 'explain'")
logger.func_exit("parser", "detect_prompt_type", "explain")
return "explain"
end
logger.debug("parser", "detect_prompt_type: detected 'generic'")
logger.func_exit("parser", "detect_prompt_type", "generic")
return "generic"
end
@@ -163,10 +229,16 @@ end
---@param content string Raw prompt content
---@return string Cleaned content
function M.clean_prompt(content)
logger.func_entry("parser", "clean_prompt", { content_length = #content })
-- Trim leading/trailing whitespace
content = content:match("^%s*(.-)%s*$")
-- Normalize multiple newlines
content = content:gsub("\n\n\n+", "\n\n")
logger.debug("parser", "clean_prompt: cleaned from " .. #content .. " chars")
logger.func_exit("parser", "clean_prompt", "length=" .. #content)
return content
end
@@ -175,7 +247,14 @@ end
---@param close_tag string Closing tag
---@return boolean
function M.has_closing_tag(line, close_tag)
return line:find(utils.escape_pattern(close_tag)) ~= nil
logger.func_entry("parser", "has_closing_tag", { line_preview = line:sub(1, 30), close_tag = close_tag })
local result = line:find(utils.escape_pattern(close_tag)) ~= nil
logger.debug("parser", "has_closing_tag: result=" .. tostring(result))
logger.func_exit("parser", "has_closing_tag", result)
return result
end
--- Check if buffer has any unclosed prompts
@@ -183,6 +262,9 @@ end
---@return boolean
function M.has_unclosed_prompts(bufnr)
bufnr = bufnr or vim.api.nvim_get_current_buf()
logger.func_entry("parser", "has_unclosed_prompts", { bufnr = bufnr })
local config = get_config_safe()
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
@@ -194,7 +276,12 @@ function M.has_unclosed_prompts(bufnr)
local _, open_count = content:gsub(escaped_open, "")
local _, close_count = content:gsub(escaped_close, "")
return open_count > close_count
local has_unclosed = open_count > close_count
logger.debug("parser", "has_unclosed_prompts: open=" .. open_count .. ", close=" .. close_count .. ", unclosed=" .. tostring(has_unclosed))
logger.func_exit("parser", "has_unclosed_prompts", has_unclosed)
return has_unclosed
end
--- Extract file references from prompt content
@@ -202,6 +289,8 @@ end
---@param content string Prompt content
---@return string[] List of file references
function M.extract_file_references(content)
logger.func_entry("parser", "extract_file_references", { content_length = #content })
local files = {}
-- Pattern: @ followed by word char, dot, underscore, or dash as FIRST char
-- Then optionally more path characters including /
@@ -209,8 +298,13 @@ function M.extract_file_references(content)
for file in content:gmatch("@([%w%._%-][%w%._%-/]*)") do
if file ~= "" then
table.insert(files, file)
logger.debug("parser", "extract_file_references: found file reference: " .. file)
end
end
logger.debug("parser", "extract_file_references: found " .. #files .. " file references")
logger.func_exit("parser", "extract_file_references", files)
return files
end
@@ -218,9 +312,16 @@ end
---@param content string Prompt content
---@return string Cleaned content without file references
function M.strip_file_references(content)
logger.func_entry("parser", "strip_file_references", { content_length = #content })
-- Remove @filename patterns but preserve @/ closing tag
-- Pattern requires first char after @ to be word char, dot, underscore, or dash (NOT /)
return content:gsub("@([%w%._%-][%w%._%-/]*)", "")
local result = content:gsub("@([%w%._%-][%w%._%-/]*)", "")
logger.debug("parser", "strip_file_references: stripped " .. (#content - #result) .. " chars")
logger.func_exit("parser", "strip_file_references", "length=" .. #result)
return result
end
--- Check if cursor is inside an unclosed prompt tag
@@ -229,6 +330,9 @@ end
---@return number|nil start_line Line where the open tag starts
function M.is_cursor_in_open_tag(bufnr)
bufnr = bufnr or vim.api.nvim_get_current_buf()
logger.func_entry("parser", "is_cursor_in_open_tag", { bufnr = bufnr })
local config = get_config_safe()
local cursor = vim.api.nvim_win_get_cursor(0)
@@ -247,14 +351,20 @@ function M.is_cursor_in_open_tag(bufnr)
for _ in line:gmatch(escaped_open) do
open_count = open_count + 1
last_open_line = line_num
logger.debug("parser", "is_cursor_in_open_tag: found open tag at line " .. line_num)
end
-- Count closes on this line
for _ in line:gmatch(escaped_close) do
close_count = close_count + 1
logger.debug("parser", "is_cursor_in_open_tag: found close tag at line " .. line_num)
end
end
local is_inside = open_count > close_count
logger.debug("parser", "is_cursor_in_open_tag: open=" .. open_count .. ", close=" .. close_count .. ", is_inside=" .. tostring(is_inside) .. ", last_open_line=" .. tostring(last_open_line))
logger.func_exit("parser", "is_cursor_in_open_tag", { is_inside = is_inside, last_open_line = last_open_line })
return is_inside, is_inside and last_open_line or nil
end
@@ -263,10 +373,14 @@ end
---@return string|nil prefix The text after @ being typed, or nil if not typing a file ref
function M.get_file_ref_prefix(bufnr)
bufnr = bufnr or vim.api.nvim_get_current_buf()
logger.func_entry("parser", "get_file_ref_prefix", { bufnr = bufnr })
local cursor = vim.api.nvim_win_get_cursor(0)
local line = vim.api.nvim_buf_get_lines(bufnr, cursor[1] - 1, cursor[1], false)[1]
if not line then
logger.debug("parser", "get_file_ref_prefix: no line at cursor")
logger.func_exit("parser", "get_file_ref_prefix", nil)
return nil
end
@@ -279,10 +393,17 @@ function M.get_file_ref_prefix(bufnr)
-- Make sure it's not the closing tag pattern
if prefix and before_cursor:sub(-2) == "@/" then
logger.debug("parser", "get_file_ref_prefix: closing tag detected, returning nil")
logger.func_exit("parser", "get_file_ref_prefix", nil)
return nil
end
logger.debug("parser", "get_file_ref_prefix: prefix=" .. tostring(prefix))
logger.func_exit("parser", "get_file_ref_prefix", prefix)
return prefix
end
logger.info("parser", "Parser module loaded")
return M