diff --git a/.changeset/experimental-lsp-hover.md b/.changeset/experimental-lsp-hover.md new file mode 100644 index 0000000..3da32df --- /dev/null +++ b/.changeset/experimental-lsp-hover.md @@ -0,0 +1,5 @@ +--- +"@flemma-dev/flemma.nvim": minor +--- + +Added experimental in-process LSP server for chat buffers with hover and goto-definition support. Enable with `experimental = { lsp = true }` in setup. Every buffer position returns a hover result: segments (expressions, thinking blocks, tool use/result, text) show structured dumps, role markers show message summaries with segment breakdowns, and frontmatter shows language and code. Goto-definition (`gd`, ``, etc.) on `@./file` references and `{{ include() }}` expressions jumps to the referenced file, reusing the navigation module's path resolution. diff --git a/Makefile b/Makefile index d6bbd63..e5e8ed0 100644 --- a/Makefile +++ b/Makefile @@ -66,7 +66,7 @@ develop: [\"\$$gpt\"] = \"openai gpt-5.2\", \ }, \ diagnostics = { enabled = true }, \ - logging = { enabled = true }, \ + logging = { enabled = true, level = "TRACE" }, \ editing = { auto_write = true }, \ tools = { modules = { \"extras.flemma.tools.calculator\" } }, \ }) \ diff --git a/lua/flemma/ast/query.lua b/lua/flemma/ast/query.lua index 45b0329..2982898 100644 --- a/lua/flemma/ast/query.lua +++ b/lua/flemma/ast/query.lua @@ -85,54 +85,56 @@ function M.find_segment_at_position(doc, lnum, col) for _, msg in ipairs(doc.messages) do local msg_start = msg.position.start_line local msg_end = msg.position.end_line or msg_start - if lnum < msg_start or lnum > msg_end then - goto continue_msg - end - ---@type flemma.ast.Segment|nil - local fallback_seg = nil + if lnum >= msg_start and lnum <= msg_end then + ---@type flemma.ast.Segment|nil + local fallback_seg = nil + for _, seg in ipairs(msg.segments) do + if not seg.position then + goto continue_seg + end - for _, seg in ipairs(msg.segments) do - if not seg.position then - goto continue_seg - end + local seg_start = seg.position.start_line + local seg_end = seg.position.end_line or seg_start - local seg_start = seg.position.start_line - local seg_end = seg.position.end_line or seg_start + if lnum < seg_start or lnum > seg_end then + goto continue_seg + end - if lnum < seg_start or lnum > seg_end then - goto continue_seg - end + -- Line matches. Refine with column if available. + if lnum == seg_start and seg.position.start_col and seg.position.end_col then + if col >= seg.position.start_col and col <= seg.position.end_col then + return seg, msg + end + goto continue_seg + end - -- Line matches. Refine with column if available. - if lnum == seg_start and seg.position.start_col and seg.position.end_col then - if col >= seg.position.start_col and col <= seg.position.end_col then + -- Multi-line hit beyond first line — definite match + if lnum > seg_start then return seg, msg end - goto continue_seg - end - -- Multi-line hit beyond first line — definite match - if lnum > seg_start then - return seg, msg - end + -- Single-line segment without column info — save as fallback + -- so column-aware segments on the same line get a chance to match first + if not seg.position.start_col then + fallback_seg = seg + end - -- Single-line segment without column info — save as fallback - if not fallback_seg then - fallback_seg = seg + ::continue_seg:: end - ::continue_seg:: - end + -- Return fallback if no column-specific match found + if fallback_seg then + return fallback_seg, msg + end - -- Return fallback if no column-specific match found - if fallback_seg then - return fallback_seg, msg + -- Cursor is within the message range but no segment matched + -- (e.g., on the @Role: marker line). Return the message itself. + return nil, msg end - - ::continue_msg:: end return nil, nil end + return M diff --git a/lua/flemma/config.lua b/lua/flemma/config.lua index ec6022e..c71b3e9 100644 --- a/lua/flemma/config.lua +++ b/lua/flemma/config.lua @@ -146,6 +146,9 @@ ---@field insert flemma.config.InsertKeymaps ---@field enabled boolean +---@class flemma.config.Experimental +---@field lsp boolean Enable in-process LSP for .chat buffers + ---User-facing setup options — every field is optional (merged with defaults). ---@class flemma.Config.Opts ---@field defaults? { dark: { bg: string, fg: string }, light: { bg: string, fg: string } } @@ -168,6 +171,7 @@ ---@field keymaps? flemma.config.Keymaps ---@field sandbox? flemma.config.SandboxConfig ---@field diagnostics? flemma.config.Diagnostics +---@field experimental? flemma.config.Experimental ---Full resolved config (all fields present after merging with defaults). ---@class flemma.Config : flemma.Config.Opts @@ -190,6 +194,7 @@ ---@field keymaps flemma.config.Keymaps ---@field sandbox flemma.config.SandboxConfig ---@field diagnostics flemma.config.Diagnostics +---@field experimental flemma.config.Experimental ---@type flemma.Config return { @@ -347,4 +352,7 @@ return { }, }, }, + experimental = { + lsp = vim.lsp ~= nil, + }, } diff --git a/lua/flemma/init.lua b/lua/flemma/init.lua index 61c5c14..d7b716c 100644 --- a/lua/flemma/init.lua +++ b/lua/flemma/init.lua @@ -22,6 +22,7 @@ local tools_presets = require("flemma.tools.presets") local tools_approval = require("flemma.tools.approval") local cursor = require("flemma.cursor") local sandbox = require("flemma.sandbox") +local lsp = require("flemma.lsp") -- Module configuration (will hold merged user opts and defaults) local config = {} @@ -145,6 +146,11 @@ M.setup = function(user_opts) -- Register built-in sandbox backends and validate availability sandbox.setup() + -- Set up experimental LSP if enabled + if config.experimental and config.experimental.lsp then + lsp.setup() + end + -- Defer sandbox backend check until the user enters a .chat buffer. -- By that time, other plugins may have registered additional backends. if config.sandbox and config.sandbox.enabled then diff --git a/lua/flemma/lsp.lua b/lua/flemma/lsp.lua new file mode 100644 index 0000000..336eb77 --- /dev/null +++ b/lua/flemma/lsp.lua @@ -0,0 +1,387 @@ +--- In-process LSP server for .chat buffers +--- Provides hover inspection of AST nodes. Experimental feature. +---@class flemma.Lsp +local M = {} + +local ast = require("flemma.ast") +local log = require("flemma.logging") +local navigation = require("flemma.navigation") +local parser = require("flemma.parser") +local json = require("flemma.utilities.json") + +---Serialize an AST segment to a markdown hover string. +---@param seg flemma.ast.Segment +---@param msg flemma.ast.MessageNode +---@return string markdown +local function segment_to_markdown(seg, msg) + local lines = {} + + -- Header: segment kind + local kind_label = seg.kind:gsub("_?(%a)([%w]*)", function(first, rest) return first:upper() .. rest end) .. "Segment" + table.insert(lines, "_" .. kind_label .. "_") + table.insert(lines, "") + table.insert(lines, "**Role:** " .. msg.role) + + -- Kind-specific fields + if seg.kind == "expression" then + ---@cast seg flemma.ast.ExpressionSegment + table.insert(lines, "**Code:** `" .. seg.code .. "`") + elseif seg.kind == "thinking" then + ---@cast seg flemma.ast.ThinkingSegment + table.insert(lines, "**Redacted:** " .. tostring(seg.redacted or false)) + if seg.signature then + table.insert(lines, "**Signature provider:** " .. seg.signature.provider) + end + table.insert(lines, "") + table.insert(lines, "**Content:**") + table.insert(lines, "") + table.insert(lines, seg.content) + elseif seg.kind == "tool_use" then + ---@cast seg flemma.ast.ToolUseSegment + table.insert(lines, "**Tool:** `" .. seg.name .. "`") + table.insert(lines, "**ID:** `" .. seg.id .. "`") + table.insert(lines, "") + table.insert(lines, "**Input:**") + table.insert(lines, "```json") + table.insert(lines, json.encode(seg.input)) + table.insert(lines, "```") + elseif seg.kind == "tool_result" then + ---@cast seg flemma.ast.ToolResultSegment + table.insert(lines, "**Tool Use ID:** `" .. seg.tool_use_id .. "`") + table.insert(lines, "**Error:** " .. tostring(seg.is_error)) + if seg.status then + table.insert(lines, "**Status:** " .. seg.status) + end + table.insert(lines, "") + table.insert(lines, "**Content:**") + table.insert(lines, "```") + table.insert(lines, seg.content) + table.insert(lines, "```") + elseif seg.kind == "text" then + ---@cast seg flemma.ast.TextSegment + local preview = seg.value + if #preview > 200 then + preview = preview:sub(1, 200) .. "..." + end + table.insert(lines, "**Content:** " .. vim.trim(preview)) + elseif seg.kind == "aborted" then + ---@cast seg flemma.ast.AbortedSegment + table.insert(lines, "**Message:** " .. seg.message) + end + + -- Position info + if seg.position then + table.insert(lines, "") + local pos_parts = { "L" .. seg.position.start_line } + if seg.position.start_col then + pos_parts[1] = pos_parts[1] .. ":C" .. seg.position.start_col + end + if seg.position.end_line then + local end_part = "L" .. seg.position.end_line + if seg.position.end_col then + end_part = end_part .. ":C" .. seg.position.end_col + end + table.insert(pos_parts, end_part) + end + table.insert(lines, "**Position:** " .. table.concat(pos_parts, " \u{2192} ")) + end + + return table.concat(lines, "\n") +end + +---Serialize a message node (role marker) to a markdown hover string. +---@param msg flemma.ast.MessageNode +---@return string markdown +local function message_to_markdown(msg) + local lines = {} + table.insert(lines, "_MessageNode_") + table.insert(lines, "") + table.insert(lines, "**Role:** " .. msg.role) + table.insert(lines, "**Segments:** " .. #msg.segments) + + -- Summarize segment kinds + local kind_counts = {} ---@type table + for _, seg in ipairs(msg.segments) do + kind_counts[seg.kind] = (kind_counts[seg.kind] or 0) + 1 + end + local summary_parts = {} + for kind, count in pairs(kind_counts) do + table.insert(summary_parts, kind .. "=" .. count) + end + table.sort(summary_parts) + if #summary_parts > 0 then + table.insert(lines, "**Breakdown:** " .. table.concat(summary_parts, ", ")) + end + + if msg.position then + table.insert(lines, "") + table.insert( + lines, + "**Position:** L" + .. msg.position.start_line + .. " \u{2192} L" + .. (msg.position.end_line or msg.position.start_line) + ) + end + + return table.concat(lines, "\n") +end + +---Serialize a frontmatter node to a markdown hover string. +---@param fm flemma.ast.FrontmatterNode +---@return string markdown +local function frontmatter_to_markdown(fm) + local lines = {} + table.insert(lines, "_FrontmatterNode_") + table.insert(lines, "") + table.insert(lines, "**Language:** " .. fm.language) + table.insert(lines, "**Length:** " .. #fm.code .. " bytes") + + if fm.position then + table.insert(lines, "") + table.insert( + lines, + "**Position:** L" .. fm.position.start_line .. " \u{2192} L" .. (fm.position.end_line or fm.position.start_line) + ) + end + + table.insert(lines, "") + table.insert(lines, "**Code:**") + table.insert(lines, "```" .. fm.language) + table.insert(lines, fm.code) + table.insert(lines, "```") + + return table.concat(lines, "\n") +end + +---Extract and validate buffer + position from LSP textDocument params. +---Converts LSP 0-indexed positions to 1-indexed AST coordinates. +---@param params table LSP TextDocumentPositionParams +---@param label string Log label for this request type (e.g. "hover", "definition") +---@return integer|nil bufnr Valid buffer number, or nil on failure +---@return integer lnum 1-indexed line number +---@return integer col 1-indexed column number +local function resolve_params(params, label) + local uri = params.textDocument.uri + local bufnr = vim.uri_to_bufnr(uri) + + if not vim.api.nvim_buf_is_valid(bufnr) then + log.warn("lsp " .. label .. ": buffer " .. bufnr .. " is invalid (uri=" .. uri .. ")") + return nil, 0, 0 + end + + local lnum = params.position.line + 1 + local col = params.position.character + 1 + log.debug("lsp " .. label .. ": " .. uri .. "#L" .. lnum .. "C" .. col .. " \u{2192} bufnr=" .. bufnr) + return bufnr, lnum, col +end + +---Build an LSP Hover response from a markdown string. +---@param markdown string +---@return table result LSP Hover response +local function hover_response(markdown) + return { + contents = { + kind = "markdown", + value = markdown, + }, + } +end + +---Handle a textDocument/hover request. +---@param params table LSP HoverParams +---@return table|nil result LSP Hover response or nil +local function handle_hover(params) + local bufnr, lnum, col = resolve_params(params, "hover") + if not bufnr then + return nil + end + + local doc = parser.get_parsed_document(bufnr) + + log.debug( + "lsp hover: parsed document with " + .. #doc.messages + .. " messages, " + .. #doc.errors + .. " errors" + .. (doc.frontmatter and ", has frontmatter" or "") + ) + + local seg, msg = ast.find_segment_at_position(doc, lnum, col) + + if seg and msg then + -- Build a concise segment identity for the log + local seg_detail = seg.kind + if seg.kind == "expression" then + ---@cast seg flemma.ast.ExpressionSegment + seg_detail = seg_detail .. " code=" .. seg.code:sub(1, 40) + elseif seg.kind == "tool_use" then + ---@cast seg flemma.ast.ToolUseSegment + seg_detail = seg_detail .. " name=" .. seg.name .. " id=" .. seg.id + elseif seg.kind == "tool_result" then + ---@cast seg flemma.ast.ToolResultSegment + seg_detail = seg_detail .. " tool_use_id=" .. seg.tool_use_id .. " error=" .. tostring(seg.is_error) + elseif seg.kind == "thinking" then + ---@cast seg flemma.ast.ThinkingSegment + seg_detail = seg_detail .. " len=" .. #seg.content .. " redacted=" .. tostring(seg.redacted or false) + elseif seg.kind == "text" then + ---@cast seg flemma.ast.TextSegment + seg_detail = seg_detail .. " len=" .. #seg.value + end + + log.debug("lsp hover: matched " .. seg_detail .. " in @" .. msg.role .. " message") + + local markdown = segment_to_markdown(seg, msg) + log.trace("lsp hover: response markdown (" .. #markdown .. " bytes):\n" .. markdown) + return hover_response(markdown) + end + + -- No segment but within a message (e.g., role marker line) + if msg then + log.debug("lsp hover: role marker for @" .. msg.role .. " at L" .. lnum) + return hover_response(message_to_markdown(msg)) + end + + -- Check frontmatter + local fm = doc.frontmatter + if fm and fm.position then + ---@cast fm flemma.ast.FrontmatterNode + local pos = fm.position --[[@as flemma.ast.Position]] + local fm_end = pos.end_line or pos.start_line + if lnum >= pos.start_line and lnum <= fm_end then + log.debug("lsp hover: frontmatter (" .. fm.language .. ") at L" .. lnum) + return hover_response(frontmatter_to_markdown(fm)) + end + end + + log.debug("lsp hover: no node at L" .. lnum .. ":C" .. col .. " in buffer " .. bufnr) + return nil +end + +---Handle a textDocument/definition request. +---Resolves include expressions (@./file, {{ include() }}) to file locations. +---@param params table LSP DefinitionParams +---@return table|nil result LSP Location or nil +local function handle_definition(params) + local bufnr, lnum, col = resolve_params(params, "definition") + if not bufnr then + return nil + end + + local resolved_path = navigation.resolve_include_path(bufnr, lnum, col) + if not resolved_path then + log.debug("lsp definition: no include path resolved") + return nil + end + + if vim.fn.filereadable(resolved_path) ~= 1 then + log.debug("lsp definition: resolved path not readable: " .. resolved_path) + return nil + end + + log.debug("lsp definition: jumping to " .. resolved_path) + return { + uri = vim.uri_from_fname(resolved_path), + range = { + start = { line = 0, character = 0 }, + ["end"] = { line = 0, character = 0 }, + }, + } +end + +---Create the in-process LSP server dispatch table. +---@param dispatchers vim.lsp.rpc.Dispatchers +---@return vim.lsp.rpc.PublicClient +local function create_server(dispatchers) + local closing = false + log.debug("lsp: in-process server created") + + return { + request = function(method, params, callback) + log.trace("lsp server: request " .. method) + if method == "initialize" then + log.debug("lsp server: initialize — advertising hoverProvider, definitionProvider") + callback(nil, { + capabilities = { + hoverProvider = true, + definitionProvider = true, + }, + }) + return true, 1 + elseif method == "shutdown" then + log.debug("lsp server: shutdown requested") + closing = true + callback(nil, nil) + return true, 2 + elseif method == "textDocument/hover" then + local result = handle_hover(params) + log.debug("lsp server: hover response " .. (result and "returned" or "nil (no match)")) + callback(nil, result) + return true, 3 + elseif method == "textDocument/definition" then + local result = handle_definition(params) + log.debug("lsp server: definition response " .. (result and "returned" or "nil (no match)")) + callback(nil, result) + return true, 4 + else + log.debug("lsp server: unhandled method " .. method) + callback(nil, nil) + return true, 5 + end + end, + + notify = function(method, _params) + log.trace("lsp server: notify " .. method) + if method == "exit" then + log.info("lsp server: exit notification — shutting down") + dispatchers.on_exit(0, 0) + end + return true + end, + + is_closing = function() + return closing + end, + + terminate = function() + log.debug("lsp server: terminate called") + closing = true + end, + } +end + +---Attach the LSP client to a buffer. +---Uses vim.lsp.start() which automatically deduplicates by name + root_dir. +---@param bufnr integer +function M.attach(bufnr) + local buf_name = vim.api.nvim_buf_get_name(bufnr) + log.debug("lsp: attaching to buffer " .. bufnr .. " (" .. (buf_name ~= "" and buf_name or "") .. ")") + if buf_name == "" then + log.warn("lsp: buffer " .. bufnr .. " has no name — URI resolution may fail for hover requests") + end + vim.lsp.start({ + name = "flemma", + cmd = create_server, + root_dir = vim.fn.getcwd(), + }, { + bufnr = bufnr, + }) +end + +---Set up the LSP server. Registers a FileType autocmd for chat buffers. +---Only call this when experimental.lsp is enabled. +function M.setup() + log.info("lsp: experimental LSP server enabled") + local augroup = vim.api.nvim_create_augroup("FlemmaLsp", { clear = true }) + + vim.api.nvim_create_autocmd("FileType", { + group = augroup, + pattern = "chat", + callback = function(ev) + M.attach(ev.buf) + end, + }) +end + +return M diff --git a/lua/flemma/navigation.lua b/lua/flemma/navigation.lua index cf88101..893374e 100644 --- a/lua/flemma/navigation.lua +++ b/lua/flemma/navigation.lua @@ -47,15 +47,20 @@ function M.find_prev_message() return false end ----Resolve the file path for an include expression under the cursor. +---Resolve the file path for an include expression at a given position. ---Evaluates the expression using the real include() and checks the result ---for a symbols.SOURCE_PATH tag to determine the originating file. +---When lnum/col are omitted, reads the current cursor position. ---@param bufnr integer Buffer number ----@return string|nil resolved_path Absolute file path, or nil if cursor is not on an include expression -function M.resolve_include_path(bufnr) - local cursor_pos = vim.api.nvim_win_get_cursor(0) - local lnum = cursor_pos[1] -- 1-indexed - local col = cursor_pos[2] + 1 -- 0-indexed -> 1-indexed +---@param lnum? integer 1-indexed line number (defaults to cursor line) +---@param col? integer 1-indexed column number (defaults to cursor column) +---@return string|nil resolved_path Absolute file path, or nil if position is not on an include expression +function M.resolve_include_path(bufnr, lnum, col) + if not lnum or not col then + local cursor_pos = vim.api.nvim_win_get_cursor(0) + lnum = lnum or cursor_pos[1] -- 1-indexed + col = col or (cursor_pos[2] + 1) -- 0-indexed -> 1-indexed + end log.trace("navigation: resolve_include_path at line=" .. lnum .. " col=" .. col .. " buf=" .. bufnr) local doc = parser.get_parsed_document(bufnr) diff --git a/lua/flemma/parser.lua b/lua/flemma/parser.lua index 4674d86..f73d0c6 100644 --- a/lua/flemma/parser.lua +++ b/lua/flemma/parser.lua @@ -113,10 +113,10 @@ local function parse_segments(text, base_line) if next_kind == "expr" then local line, col = char_to_line_col(next_start) - local _, end_col = char_to_line_col(next_end) + local end_line, end_col = char_to_line_col(next_end) table.insert( segments, - ast.expression(payload --[[@as string]], { start_line = line, start_col = col, end_col = end_col }) + ast.expression(payload --[[@as string]], { start_line = line, start_col = col, end_line = end_line, end_col = end_col }) ) elseif next_kind == "file" then ---@cast payload string @@ -140,8 +140,8 @@ local function parse_segments(text, base_line) ---@cast cleaned_path string local escaped_path = lua_string_escape(cleaned_path) local line, col = char_to_line_col(next_start) - -- end_col is the end of the @./file reference, excluding trailing punctuation - local _, end_col = char_to_line_col(next_end - #trailing_punct) + -- end position is the end of the @./file reference, excluding trailing punctuation + local end_line, end_col = char_to_line_col(next_end - #trailing_punct) -- Build the include() expression code local opts_parts = { "binary = true" } @@ -150,7 +150,7 @@ local function parse_segments(text, base_line) end local code = "include('" .. escaped_path .. "', { " .. table.concat(opts_parts, ", ") .. " })" - table.insert(segments, ast.expression(code, { start_line = line, start_col = col, end_col = end_col })) + table.insert(segments, ast.expression(code, { start_line = line, start_col = col, end_line = end_line, end_col = end_col })) -- Emit trailing punctuation as a separate text segment if #trailing_punct > 0 then diff --git a/tests/flemma/ast_spec.lua b/tests/flemma/ast_spec.lua index 40d3436..9420a7b 100644 --- a/tests/flemma/ast_spec.lua +++ b/tests/flemma/ast_spec.lua @@ -254,6 +254,158 @@ describe("Parser", function() end) end) +describe("Expression segment positions", function() + it("sets end_col on {{ }} expressions", function() + local doc = parser.parse_lines({ + "@You:", + "Hello {{ name }} world", + }) + local segs = doc.messages[1].segments + -- Find the expression segment + local expr_seg + for _, seg in ipairs(segs) do + if seg.kind == "expression" then + expr_seg = seg + break + end + end + assert.is_not_nil(expr_seg) + assert.is_not_nil(expr_seg.position.start_col) + assert.is_not_nil(expr_seg.position.end_col) + assert.is_true(expr_seg.position.end_col > expr_seg.position.start_col) + end) + + it("sets end_col on @./ file references", function() + local doc = parser.parse_lines({ + "@You:", + "See @./readme.md for details", + }) + local segs = doc.messages[1].segments + local expr_seg + for _, seg in ipairs(segs) do + if seg.kind == "expression" then + expr_seg = seg + break + end + end + assert.is_not_nil(expr_seg) + assert.is_not_nil(expr_seg.position.start_col) + assert.is_not_nil(expr_seg.position.end_col) + end) + + it("excludes trailing punctuation from @./ end_col", function() + local doc = parser.parse_lines({ + "@You:", + "Check @./file.txt, then continue", + }) + local segs = doc.messages[1].segments + local expr_seg + for _, seg in ipairs(segs) do + if seg.kind == "expression" then + expr_seg = seg + break + end + end + assert.is_not_nil(expr_seg) + -- The comma should NOT be included in the expression range + -- Extract the path from the include() call and verify it excludes trailing comma + local path_in_code = expr_seg.code:match("include%('([^']+)'") + assert.is_not_nil(path_in_code) + assert.is_nil(path_in_code:find(","), "Trailing comma should not be in file path") + end) +end) + +describe("find_segment_at_position", function() + it("finds expression segment by line and column", function() + local doc = parser.parse_lines({ + "@You:", + "Hello {{ name }} world", + }) + local seg, msg = ast.find_segment_at_position(doc, 2, 8) + assert.is_not_nil(seg) + assert.equals("expression", seg.kind) + assert.equals("You", msg.role) + end) + + it("returns text segment when not on expression", function() + local doc = parser.parse_lines({ + "@You:", + "Hello {{ name }} world", + }) + local seg, msg = ast.find_segment_at_position(doc, 2, 1) + assert.is_not_nil(seg) + assert.equals("text", seg.kind) + assert.equals("You", msg.role) + end) + + it("returns nil for line outside any message", function() + local doc = parser.parse_lines({ + "@You:", + "Hello", + }) + local seg, msg = ast.find_segment_at_position(doc, 99, 1) + assert.is_nil(seg) + assert.is_nil(msg) + end) + + it("finds thinking segment by line", function() + local doc = parser.parse_lines({ + "@Assistant:", + "", + "I need to think about this", + "", + "Here is my answer", + }) + local seg, msg = ast.find_segment_at_position(doc, 3, 1) + assert.is_not_nil(seg) + assert.equals("thinking", seg.kind) + assert.equals("Assistant", msg.role) + end) + + it("finds tool_use segment by line", function() + local doc = parser.parse_lines({ + "@Assistant:", + "**Tool Use:** `bash` (`call_123`)", + "```json", + '{"command": "ls"}', + "```", + }) + local seg, msg = ast.find_segment_at_position(doc, 2, 1) + assert.is_not_nil(seg) + assert.equals("tool_use", seg.kind) + assert.equals("Assistant", msg.role) + end) + + it("returns message without segment on role marker line", function() + local doc = parser.parse_lines({ + "@You:", + "Hello world", + }) + local seg, msg = ast.find_segment_at_position(doc, 1, 1) + assert.is_nil(seg) + assert.is_not_nil(msg) + assert.equals("You", msg.role) + end) + + it("distinguishes adjacent expressions on same line", function() + local doc = parser.parse_lines({ + "@You:", + "{{ a }} and {{ b }}", + }) + -- First expression + local seg1 = ast.find_segment_at_position(doc, 2, 1) + assert.is_not_nil(seg1) + assert.equals("expression", seg1.kind) + assert.equals(" a ", seg1.code) + + -- Second expression + local seg2 = ast.find_segment_at_position(doc, 2, 14) + assert.is_not_nil(seg2) + assert.equals("expression", seg2.kind) + assert.equals(" b ", seg2.code) + end) +end) + describe("Processor", function() local function setup_fixtures() os.execute("mkdir -p tests/fixtures") diff --git a/tests/flemma/lsp_spec.lua b/tests/flemma/lsp_spec.lua new file mode 100644 index 0000000..0a59a4b --- /dev/null +++ b/tests/flemma/lsp_spec.lua @@ -0,0 +1,253 @@ +describe("Flemma LSP", function() + local flemma + + before_each(function() + -- Clear the FlemmaLsp augroup to prevent stale autocmds from previous tests + vim.api.nvim_create_augroup("FlemmaLsp", { clear = true }) + -- Stop any lingering LSP clients + for _, client in pairs(vim.lsp.get_clients({ name = "flemma" })) do + client:stop(true) + end + vim.cmd("silent! %bdelete!") + package.loaded["flemma"] = nil + package.loaded["flemma.lsp"] = nil + package.loaded["flemma.parser"] = nil + package.loaded["flemma.state"] = nil + package.loaded["flemma.ast"] = nil + package.loaded["flemma.ast.query"] = nil + package.loaded["flemma.ast.nodes"] = nil + flemma = require("flemma") + end) + + after_each(function() + for _, client in pairs(vim.lsp.get_clients({ name = "flemma" })) do + client:stop(true) + end + vim.cmd("silent! %bdelete!") + end) + + local test_counter = 0 + + --- Helper: create a named chat buffer with given lines, attach LSP, return bufnr and client + ---@param lines string[] + ---@return integer bufnr + ---@return vim.lsp.Client client + local function setup_chat_buffer(lines) + flemma.setup({ experimental = { lsp = true } }) + + test_counter = test_counter + 1 + local bufnr = vim.api.nvim_create_buf(true, false) + -- Named buffers are required for URI resolution in the LSP hover handler + vim.api.nvim_buf_set_name(bufnr, "/tmp/flemma_lsp_test_" .. test_counter .. ".chat") + vim.api.nvim_set_current_buf(bufnr) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + vim.bo[bufnr].filetype = "chat" + vim.cmd("doautocmd FileType") + + vim.wait(1000, function() + return #vim.lsp.get_clients({ name = "flemma", bufnr = bufnr }) > 0 + end) + + local clients = vim.lsp.get_clients({ name = "flemma", bufnr = bufnr }) + assert.is_true(#clients > 0, "Client should be attached") + return bufnr, clients[1] + end + + --- Helper: make a synchronous hover request + ---@param client vim.lsp.Client + ---@param bufnr integer + ---@param line integer 0-indexed line + ---@param character integer 0-indexed column + ---@return table|nil result + local function hover_sync(client, bufnr, line, character) + local response = client:request_sync("textDocument/hover", { + textDocument = { uri = vim.uri_from_bufnr(bufnr) }, + position = { line = line, character = character }, + }, 2000, bufnr) + if response and response.result then + return response.result + end + return nil + end + + it("attaches to chat buffers when experimental.lsp is enabled", function() + flemma.setup({ experimental = { lsp = true } }) + + local bufnr = vim.api.nvim_create_buf(true, false) + vim.api.nvim_set_current_buf(bufnr) + vim.bo[bufnr].filetype = "chat" + vim.cmd("doautocmd FileType") + + local clients = vim.lsp.get_clients({ name = "flemma", bufnr = bufnr }) + assert.is_true(#clients > 0, "Expected flemma LSP client to be attached") + end) + + it("does not attach when experimental.lsp is disabled", function() + flemma.setup({ experimental = { lsp = false } }) + + local bufnr = vim.api.nvim_create_buf(true, false) + vim.api.nvim_set_current_buf(bufnr) + vim.bo[bufnr].filetype = "chat" + vim.cmd("doautocmd FileType") + + local clients = vim.lsp.get_clients({ name = "flemma", bufnr = bufnr }) + assert.equals(0, #clients, "Expected no flemma LSP client when disabled") + end) + + it("returns hover for expression segment", function() + local bufnr, client = setup_chat_buffer({ + "@You:", + "Hello {{ name }}", + }) + + local result = hover_sync(client, bufnr, 1, 8) -- 0-indexed, on "{{ name }}" + assert.is_not_nil(result, "Expected hover result") + assert.is_not_nil(result.contents) + assert.equals("markdown", result.contents.kind) + assert.is_truthy(result.contents.value:find("ExpressionSegment")) + assert.is_truthy(result.contents.value:find("name")) + end) + + it("returns hover for plain text", function() + local bufnr, client = setup_chat_buffer({ + "@You:", + "Hello world", + }) + + local result = hover_sync(client, bufnr, 1, 2) -- on "Hello" + assert.is_not_nil(result) + assert.is_truthy(result.contents.value:find("TextSegment")) + end) + + it("returns hover with full thinking content (no truncation)", function() + local long_thought = string.rep("This is a long thought. ", 100) + local bufnr, client = setup_chat_buffer({ + "@Assistant:", + "", + long_thought, + "", + "Answer here", + }) + + local result = hover_sync(client, bufnr, 2, 0) -- inside thinking block + assert.is_not_nil(result) + assert.is_truthy(result.contents.value:find("ThinkingSegment")) + assert.is_truthy(result.contents.value:find("This is a long thought")) + assert.is_true(#result.contents.value > #long_thought) + end) + + it("returns hover for tool_use segment", function() + local bufnr, client = setup_chat_buffer({ + "@Assistant:", + "**Tool Use:** `bash` (`call_abc123`)", + "```json", + '{"command": "ls -la"}', + "```", + }) + + local result = hover_sync(client, bufnr, 1, 5) -- on tool use header + assert.is_not_nil(result) + assert.is_truthy(result.contents.value:find("ToolUseSegment")) + assert.is_truthy(result.contents.value:find("bash")) + assert.is_truthy(result.contents.value:find("call_abc123")) + end) + + --- Helper: make a synchronous definition request + ---@param client vim.lsp.Client + ---@param bufnr integer + ---@param line integer 0-indexed line + ---@param character integer 0-indexed column + ---@return table|nil result + local function definition_sync(client, bufnr, line, character) + local response = client:request_sync("textDocument/definition", { + textDocument = { uri = vim.uri_from_bufnr(bufnr) }, + position = { line = line, character = character }, + }, 2000, bufnr) + if response and response.result then + return response.result + end + return nil + end + + it("returns definition for @./file reference", function() + -- Name the buffer inside fixtures/ so @./include_target.txt resolves + local fixture_dir = vim.fn.fnamemodify("tests/fixtures", ":p") + local target_path = fixture_dir .. "include_target.txt" + + flemma.setup({ experimental = { lsp = true } }) + + test_counter = test_counter + 1 + local bufnr = vim.api.nvim_create_buf(true, false) + vim.api.nvim_buf_set_name(bufnr, fixture_dir .. "lsp_def_test_" .. test_counter .. ".chat") + vim.api.nvim_set_current_buf(bufnr) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { + "@You:", + "See @./include_target.txt for details", + }) + vim.bo[bufnr].filetype = "chat" + vim.cmd("doautocmd FileType") + + vim.wait(1000, function() + return #vim.lsp.get_clients({ name = "flemma", bufnr = bufnr }) > 0 + end) + + local clients = vim.lsp.get_clients({ name = "flemma", bufnr = bufnr }) + assert.is_true(#clients > 0, "Client should be attached") + local client = clients[1] + + -- Position on the file reference (0-indexed: line 1, on the @./ path) + local result = definition_sync(client, bufnr, 1, 6) + assert.is_not_nil(result, "Expected definition result for file reference") + assert.equals(vim.uri_from_fname(target_path), result.uri) + end) + + it("returns nil for definition on non-include expression", function() + local bufnr, client = setup_chat_buffer({ + "@You:", + "Hello {{ 1 + 1 }} world", + }) + + local result = definition_sync(client, bufnr, 1, 10) -- on the expression + assert.is_nil(result, "Non-include expression should not have a definition") + end) + + it("returns nil for definition on plain text", function() + local bufnr, client = setup_chat_buffer({ + "@You:", + "Just some plain text", + }) + + local result = definition_sync(client, bufnr, 1, 5) + assert.is_nil(result, "Plain text should not have a definition") + end) + + it("returns hover for role marker line", function() + local bufnr, client = setup_chat_buffer({ + "@You:", + "Hello world", + }) + + local result = hover_sync(client, bufnr, 0, 0) -- 0-indexed, on "@You:" line + assert.is_not_nil(result, "Expected hover result on role marker") + assert.equals("markdown", result.contents.kind) + assert.is_truthy(result.contents.value:find("MessageNode")) + assert.is_truthy(result.contents.value:find("You")) + assert.is_truthy(result.contents.value:find("Segments")) + end) + + it("returns hover for frontmatter", function() + local bufnr, client = setup_chat_buffer({ + "```yaml", + "model: claude-3", + "```", + "@You:", + "Hello", + }) + + local result = hover_sync(client, bufnr, 1, 0) -- 0-indexed, inside frontmatter + assert.is_not_nil(result, "Expected hover result on frontmatter") + assert.equals("markdown", result.contents.kind) + assert.is_truthy(result.contents.value:find("FrontmatterNode")) + assert.is_truthy(result.contents.value:find("yaml")) + end) +end)