From 4b62b7ee49c1be27e0e6d876d52f9b41a8bf370a Mon Sep 17 00:00:00 2001 From: Stan Angeloff Date: Mon, 9 Mar 2026 22:46:33 +0200 Subject: [PATCH 01/16] feat(ast): add find_segment_at_position query function Generic cursor-to-node lookup that returns the segment and parent message at a 1-indexed (line, col) position. Uses column refinement for inline segments (expressions) and line-only matching for multi-line segments (thinking, tool_use, tool_result). Co-Authored-By: Claude Opus 4.6 --- lua/flemma/ast/query.lua | 1 + 1 file changed, 1 insertion(+) diff --git a/lua/flemma/ast/query.lua b/lua/flemma/ast/query.lua index 45b0329..46fa0a9 100644 --- a/lua/flemma/ast/query.lua +++ b/lua/flemma/ast/query.lua @@ -135,4 +135,5 @@ function M.find_segment_at_position(doc, lnum, col) return nil, nil end + return M From 325a4bdfb987b384fa7ab2047c0027b6e848b65a Mon Sep 17 00:00:00 2001 From: Stan Angeloff Date: Mon, 9 Mar 2026 22:50:56 +0200 Subject: [PATCH 02/16] test(ast): add specs for end_col positions and find_segment_at_position Cover column-precise position tracking for {{ }} and @./ expressions, trailing punctuation exclusion, cursor-to-node resolution across segment types (text, expression, thinking, tool_use), and same-line disambiguation of adjacent expressions. Co-Authored-By: Claude Opus 4.6 --- tests/flemma/ast_spec.lua | 141 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) diff --git a/tests/flemma/ast_spec.lua b/tests/flemma/ast_spec.lua index 40d3436..aaef968 100644 --- a/tests/flemma/ast_spec.lua +++ b/tests/flemma/ast_spec.lua @@ -254,6 +254,147 @@ 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("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") From 7e3986b62512f2966c1facbebaad8ad0b6f287fd Mon Sep 17 00:00:00 2001 From: Stan Angeloff Date: Mon, 9 Mar 2026 22:51:51 +0200 Subject: [PATCH 03/16] feat(config): add experimental config section with lsp flag Add flemma.config.Experimental type with an lsp boolean (default false) to gate experimental features. This provides a clean namespace for in-development capabilities without polluting the main config. Co-Authored-By: Claude Opus 4.6 --- lua/flemma/config.lua | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lua/flemma/config.lua b/lua/flemma/config.lua index ec6022e..45918bd 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 = false, + }, } From 8b4558c727f21c53e79c02a4b6094b7224e2fc41 Mon Sep 17 00:00:00 2001 From: Stan Angeloff Date: Mon, 9 Mar 2026 22:53:23 +0200 Subject: [PATCH 04/16] feat(lsp): add in-process LSP server with hover support Implement an experimental in-process LSP server for .chat buffers using vim.lsp.start() with a Lua cmd function. The server handles textDocument/hover by resolving cursor position to an AST segment via find_segment_at_position and serializing the segment to a structured markdown hover card showing kind, role, content, and position. Co-Authored-By: Claude Opus 4.6 --- lua/flemma/lsp.lua | 196 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 lua/flemma/lsp.lua diff --git a/lua/flemma/lsp.lua b/lua/flemma/lsp.lua new file mode 100644 index 0000000..9fd2628 --- /dev/null +++ b/lua/flemma/lsp.lua @@ -0,0 +1,196 @@ +--- 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 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:sub(1, 1):upper() .. seg.kind:sub(2) .. "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 + +---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 uri = params.textDocument.uri + local bufnr = vim.uri_to_bufnr(uri) + + if not vim.api.nvim_buf_is_valid(bufnr) then + return nil + end + + -- LSP positions are 0-indexed; AST positions are 1-indexed + local lnum = params.position.line + 1 + local col = params.position.character + 1 + + local doc = parser.get_parsed_document(bufnr) + local seg, msg = ast.find_segment_at_position(doc, lnum, col) + + if not seg or not msg then + return nil + end + + local markdown = segment_to_markdown(seg, msg) + + return { + contents = { + kind = "markdown", + value = markdown, + }, + } +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 + + return { + request = function(method, params, callback) + if method == "initialize" then + callback(nil, { + capabilities = { + hoverProvider = true, + }, + }) + return true, 1 + elseif method == "shutdown" then + closing = true + callback(nil, nil) + return true, 2 + elseif method == "textDocument/hover" then + local result = handle_hover(params) + callback(nil, result) + return true, 3 + else + -- Unsupported method + callback(nil, nil) + return true, 4 + end + end, + + notify = function(method, _params) + if method == "exit" then + dispatchers.on_exit(0, 0) + end + return true + end, + + is_closing = function() + return closing + end, + + terminate = function() + 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) + 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() + 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 From 4d9647bd63a0fac16f4b4e05cf3d16d757bf9f22 Mon Sep 17 00:00:00 2001 From: Stan Angeloff Date: Mon, 9 Mar 2026 22:54:00 +0200 Subject: [PATCH 05/16] feat(init): wire experimental LSP setup behind config flag Call lsp.setup() during plugin initialization when experimental.lsp is true. The setup registers a FileType autocmd that attaches the in-process LSP client to chat buffers. Co-Authored-By: Claude Opus 4.6 --- lua/flemma/init.lua | 6 ++++++ 1 file changed, 6 insertions(+) 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 From a1117e4befc154dd95fdb6b1507a9e1a8cdf3f17 Mon Sep 17 00:00:00 2001 From: Stan Angeloff Date: Mon, 9 Mar 2026 23:03:48 +0200 Subject: [PATCH 06/16] test(lsp): add specs for experimental LSP hover Cover client attachment/non-attachment based on config flag, and hover responses for expression, text, thinking, and tool_use segments. Uses request_sync with named buffers for reliable URI resolution in the headless test environment. Co-Authored-By: Claude Opus 4.6 --- tests/flemma/lsp_spec.lua | 154 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 tests/flemma/lsp_spec.lua diff --git a/tests/flemma/lsp_spec.lua b/tests/flemma/lsp_spec.lua new file mode 100644 index 0000000..bad17bf --- /dev/null +++ b/tests/flemma/lsp_spec.lua @@ -0,0 +1,154 @@ +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("Tool_useSegment")) + assert.is_truthy(result.contents.value:find("bash")) + assert.is_truthy(result.contents.value:find("call_abc123")) + end) +end) From 0c6e6cb32c230736b292c6c604398dac345908be Mon Sep 17 00:00:00 2001 From: Stan Angeloff Date: Mon, 9 Mar 2026 23:04:48 +0200 Subject: [PATCH 07/16] chore: add changeset for experimental LSP hover Also includes formatter-applied line wrapping in parser.lua and quote normalization in test files. Co-Authored-By: Claude Opus 4.6 --- .changeset/experimental-lsp-hover.md | 5 +++++ tests/flemma/ast_spec.lua | 2 +- tests/flemma/lsp_spec.lua | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 .changeset/experimental-lsp-hover.md diff --git a/.changeset/experimental-lsp-hover.md b/.changeset/experimental-lsp-hover.md new file mode 100644 index 0000000..6b95518 --- /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 support. Enable with `experimental = { lsp = true }` in setup. Hovering over any AST node (expressions, thinking blocks, tool use/result, text) shows a structured dump of the segment, proving correct cursor-to-node detection. This is the foundation for future LSP features like goto-definition for includes. diff --git a/tests/flemma/ast_spec.lua b/tests/flemma/ast_spec.lua index aaef968..38a67f1 100644 --- a/tests/flemma/ast_spec.lua +++ b/tests/flemma/ast_spec.lua @@ -365,7 +365,7 @@ describe("find_segment_at_position", function() it("finds tool_use segment by line", function() local doc = parser.parse_lines({ "@Assistant:", - '**Tool Use:** `bash` (`call_123`)', + "**Tool Use:** `bash` (`call_123`)", "```json", '{"command": "ls"}', "```", diff --git a/tests/flemma/lsp_spec.lua b/tests/flemma/lsp_spec.lua index bad17bf..9d53ca9 100644 --- a/tests/flemma/lsp_spec.lua +++ b/tests/flemma/lsp_spec.lua @@ -139,7 +139,7 @@ describe("Flemma LSP", function() it("returns hover for tool_use segment", function() local bufnr, client = setup_chat_buffer({ "@Assistant:", - '**Tool Use:** `bash` (`call_abc123`)', + "**Tool Use:** `bash` (`call_abc123`)", "```json", '{"command": "ls -la"}', "```", From 886ed0ec1247e64aa8139b7789c2ed9567d15326 Mon Sep 17 00:00:00 2001 From: Stan Angeloff Date: Mon, 9 Mar 2026 23:10:17 +0200 Subject: [PATCH 08/16] feat(lsp): add debug and trace logging to LSP server Log lifecycle events (setup, attach, server creation) at debug level and per-request details (method dispatch, hover hits/misses with position) at trace level for diagnosing issues. Co-Authored-By: Claude Opus 4.6 --- lua/flemma/lsp.lua | 56 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/lua/flemma/lsp.lua b/lua/flemma/lsp.lua index 9fd2628..20ef869 100644 --- a/lua/flemma/lsp.lua +++ b/lua/flemma/lsp.lua @@ -4,6 +4,7 @@ local M = {} local ast = require("flemma.ast") +local log = require("flemma.logging") local parser = require("flemma.parser") local json = require("flemma.utilities.json") @@ -94,7 +95,10 @@ local function handle_hover(params) local uri = params.textDocument.uri local bufnr = vim.uri_to_bufnr(uri) + log.debug("lsp hover: uri=" .. uri .. " -> bufnr=" .. bufnr) + if not vim.api.nvim_buf_is_valid(bufnr) then + log.warn("lsp hover: buffer " .. bufnr .. " is invalid (uri=" .. uri .. ")") return nil end @@ -102,15 +106,51 @@ local function handle_hover(params) local lnum = params.position.line + 1 local col = params.position.character + 1 + log.debug("lsp hover: position LSP(L" .. params.position.line .. ":C" .. params.position.character .. ") -> AST(L" .. lnum .. ":C" .. col .. ")") + 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 not seg or not msg then + log.debug("lsp hover: no segment found at L" .. lnum .. ":C" .. col .. " in buffer " .. bufnr) return nil end + -- 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 { contents = { kind = "markdown", @@ -124,10 +164,13 @@ end ---@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") callback(nil, { capabilities = { hoverProvider = true, @@ -135,22 +178,26 @@ local function create_server(dispatchers) }) return true, 1 elseif method == "shutdown" then + log.info("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 else - -- Unsupported method + log.debug("lsp server: unhandled method " .. method) callback(nil, nil) return true, 4 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 @@ -161,6 +208,7 @@ local function create_server(dispatchers) end, terminate = function() + log.info("lsp server: terminate called") closing = true end, } @@ -170,6 +218,11 @@ end ---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, @@ -182,6 +235,7 @@ 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", { From 78389bdd313efefc8cb23cb02762f381ac03737e Mon Sep 17 00:00:00 2001 From: Stan Angeloff Date: Mon, 9 Mar 2026 23:17:43 +0200 Subject: [PATCH 09/16] refactor(lsp): demote shutdown/terminate logs from info to debug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reserve INFO for rare notable events (setup, exit). Shutdown and terminate are routine cleanup — DEBUG is the right level, matching the codebase convention where INFO appears only a handful of times. Co-Authored-By: Claude Opus 4.6 --- lua/flemma/lsp.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lua/flemma/lsp.lua b/lua/flemma/lsp.lua index 20ef869..fdcfef7 100644 --- a/lua/flemma/lsp.lua +++ b/lua/flemma/lsp.lua @@ -178,7 +178,7 @@ local function create_server(dispatchers) }) return true, 1 elseif method == "shutdown" then - log.info("lsp server: shutdown requested") + log.debug("lsp server: shutdown requested") closing = true callback(nil, nil) return true, 2 @@ -208,7 +208,7 @@ local function create_server(dispatchers) end, terminate = function() - log.info("lsp server: terminate called") + log.debug("lsp server: terminate called") closing = true end, } From b3c50fc26d6ab0c83f8e4b38e9c7930d3bb012d0 Mon Sep 17 00:00:00 2001 From: Stan Angeloff Date: Mon, 9 Mar 2026 23:17:55 +0200 Subject: [PATCH 10/16] chore(makefile): enable experimental LSP and TRACE logging in develop target Turn on experimental.lsp and set logging level to TRACE in the make develop config so new LSP instrumentation is immediately visible during manual testing. Co-Authored-By: Claude Opus 4.6 --- Makefile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index d6bbd63..40dd4cc 100644 --- a/Makefile +++ b/Makefile @@ -59,6 +59,7 @@ develop: " \ -c "lua \ require(\"flemma\").setup({ \ + experimental = { lsp = true }, \ model = \"\$$haiku\", \ parameters = { thinking = \"minimal\" }, \ presets = { \ @@ -66,7 +67,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\" } }, \ }) \ From 8b2d3cfbc558f29a4c6868d38967c75a73883d0b Mon Sep 17 00:00:00 2001 From: Stan Angeloff Date: Mon, 9 Mar 2026 23:26:21 +0200 Subject: [PATCH 11/16] feat(lsp): hover on every buffer position (role markers, frontmatter) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three-tier hover resolution ensures every position returns a result: 1. Segment-level (text, expression, thinking, tool_use, tool_result) 2. Message-level (role marker lines) — shows segment count/breakdown 3. Frontmatter — shows language, byte length, and code Restructured find_segment_at_position to return (nil, msg) when cursor is within a message range but no segment matched, enabling role marker detection without bypassing the AST. Co-Authored-By: Claude Opus 4.6 --- .changeset/experimental-lsp-hover.md | 2 +- lua/flemma/ast/query.lua | 67 ++++++------- lua/flemma/lsp.lua | 141 +++++++++++++++++++++------ tests/flemma/ast_spec.lua | 11 +++ tests/flemma/lsp_spec.lua | 30 ++++++ 5 files changed, 186 insertions(+), 65 deletions(-) diff --git a/.changeset/experimental-lsp-hover.md b/.changeset/experimental-lsp-hover.md index 6b95518..87fb4cc 100644 --- a/.changeset/experimental-lsp-hover.md +++ b/.changeset/experimental-lsp-hover.md @@ -2,4 +2,4 @@ "@flemma-dev/flemma.nvim": minor --- -Added experimental in-process LSP server for chat buffers with hover support. Enable with `experimental = { lsp = true }` in setup. Hovering over any AST node (expressions, thinking blocks, tool use/result, text) shows a structured dump of the segment, proving correct cursor-to-node detection. This is the foundation for future LSP features like goto-definition for includes. +Added experimental in-process LSP server for chat buffers with hover 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. This is the foundation for future LSP features like goto-definition for includes. diff --git a/lua/flemma/ast/query.lua b/lua/flemma/ast/query.lua index 46fa0a9..2982898 100644 --- a/lua/flemma/ast/query.lua +++ b/lua/flemma/ast/query.lua @@ -85,52 +85,53 @@ 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 diff --git a/lua/flemma/lsp.lua b/lua/flemma/lsp.lua index fdcfef7..d8b81f9 100644 --- a/lua/flemma/lsp.lua +++ b/lua/flemma/lsp.lua @@ -88,6 +88,74 @@ local function segment_to_markdown(seg, msg) 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 + +---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 @@ -121,42 +189,53 @@ local function handle_hover(params) local seg, msg = ast.find_segment_at_position(doc, lnum, col) - if not seg or not msg then - log.debug("lsp hover: no segment found at L" .. lnum .. ":C" .. col .. " in buffer " .. bufnr) - return nil - end + 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 - -- 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") - 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 - local markdown = segment_to_markdown(seg, msg) + -- 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 - log.trace("lsp hover: response markdown (" .. #markdown .. " bytes):\n" .. markdown) + -- 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 - return { - contents = { - kind = "markdown", - value = markdown, - }, - } + log.debug("lsp hover: no node at L" .. lnum .. ":C" .. col .. " in buffer " .. bufnr) + return nil end ---Create the in-process LSP server dispatch table. diff --git a/tests/flemma/ast_spec.lua b/tests/flemma/ast_spec.lua index 38a67f1..9420a7b 100644 --- a/tests/flemma/ast_spec.lua +++ b/tests/flemma/ast_spec.lua @@ -376,6 +376,17 @@ describe("find_segment_at_position", function() 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:", diff --git a/tests/flemma/lsp_spec.lua b/tests/flemma/lsp_spec.lua index 9d53ca9..4c9b7de 100644 --- a/tests/flemma/lsp_spec.lua +++ b/tests/flemma/lsp_spec.lua @@ -151,4 +151,34 @@ describe("Flemma LSP", function() assert.is_truthy(result.contents.value:find("bash")) assert.is_truthy(result.contents.value:find("call_abc123")) 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) From 73b5e111fca54d6415e24a5d5d13ce814acdc1f4 Mon Sep 17 00:00:00 2001 From: Stan Angeloff Date: Thu, 12 Mar 2026 20:22:16 +0200 Subject: [PATCH 12/16] style(lsp): use italics instead of headers in hover docs, fix PascalCase kind labels Headers (###) don't render properly in lspsaga with concealcursor=2. Switch to _italics_ for node type labels. Also fix kind_label conversion to produce PascalCase (ToolUseSegment) instead of preserving underscores (Tool_useSegment). Co-Authored-By: Claude Opus 4.6 --- lua/flemma/lsp.lua | 33 ++++++++++++++++++++++++++------- tests/flemma/lsp_spec.lua | 2 +- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/lua/flemma/lsp.lua b/lua/flemma/lsp.lua index d8b81f9..05fe0fc 100644 --- a/lua/flemma/lsp.lua +++ b/lua/flemma/lsp.lua @@ -16,8 +16,8 @@ local function segment_to_markdown(seg, msg) local lines = {} -- Header: segment kind - local kind_label = seg.kind:sub(1, 1):upper() .. seg.kind:sub(2) .. "Segment" - table.insert(lines, "### " .. kind_label) + 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) @@ -93,7 +93,7 @@ end ---@return string markdown local function message_to_markdown(msg) local lines = {} - table.insert(lines, "### MessageNode") + table.insert(lines, "_MessageNode_") table.insert(lines, "") table.insert(lines, "**Role:** " .. msg.role) table.insert(lines, "**Segments:** " .. #msg.segments) @@ -114,7 +114,13 @@ local function message_to_markdown(msg) 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)) + 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") @@ -125,14 +131,17 @@ end ---@return string markdown local function frontmatter_to_markdown(fm) local lines = {} - table.insert(lines, "### FrontmatterNode") + 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)) + 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, "") @@ -174,7 +183,17 @@ local function handle_hover(params) local lnum = params.position.line + 1 local col = params.position.character + 1 - log.debug("lsp hover: position LSP(L" .. params.position.line .. ":C" .. params.position.character .. ") -> AST(L" .. lnum .. ":C" .. col .. ")") + log.debug( + "lsp hover: position LSP(L" + .. params.position.line + .. ":C" + .. params.position.character + .. ") -> AST(L" + .. lnum + .. ":C" + .. col + .. ")" + ) local doc = parser.get_parsed_document(bufnr) diff --git a/tests/flemma/lsp_spec.lua b/tests/flemma/lsp_spec.lua index 4c9b7de..e132f32 100644 --- a/tests/flemma/lsp_spec.lua +++ b/tests/flemma/lsp_spec.lua @@ -147,7 +147,7 @@ describe("Flemma LSP", function() local result = hover_sync(client, bufnr, 1, 5) -- on tool use header assert.is_not_nil(result) - assert.is_truthy(result.contents.value:find("Tool_useSegment")) + 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) From fc53a83eb1b8fe34cb346a901fe9610f810d42af Mon Sep 17 00:00:00 2001 From: Stan Angeloff Date: Thu, 12 Mar 2026 20:27:04 +0200 Subject: [PATCH 13/16] fix(parser): always set end_line alongside end_col in expression positions Expression and file reference segments set end_col without end_line, creating inconsistent positions that downstream consumers had to special-case. Fix at the source: capture the line from char_to_line_col (previously discarded as _) and include it in the position. Reverts the hover workaround that inferred end_line from end_col. Co-Authored-By: Claude Opus 4.6 --- lua/flemma/parser.lua | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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 From 0b2f1a6bac18e8627002e7086f0c780398eb3114 Mon Sep 17 00:00:00 2001 From: Stan Angeloff Date: Thu, 12 Mar 2026 20:34:55 +0200 Subject: [PATCH 14/16] feat(lsp): add textDocument/definition for file references and includes Goto-definition on @./file references and {{ include() }} expressions jumps to the resolved file. Reuses navigation.resolve_include_path() which handles the full eval pipeline (frontmatter context, symbols, emittable SOURCE_PATH tagging). Refactored resolve_include_path to accept optional (lnum, col) params so the LSP handler can pass the position directly without manipulating the cursor. Also fixes parser to always set end_line alongside end_col for consistent Position semantics, and switches hover headers from ### to italics for lspsaga compatibility. Co-Authored-By: Claude Opus 4.6 --- .changeset/experimental-lsp-hover.md | 2 +- lua/flemma/lsp.lua | 53 ++++++++++++++++++++- lua/flemma/navigation.lua | 17 ++++--- tests/flemma/lsp_spec.lua | 69 ++++++++++++++++++++++++++++ 4 files changed, 132 insertions(+), 9 deletions(-) diff --git a/.changeset/experimental-lsp-hover.md b/.changeset/experimental-lsp-hover.md index 87fb4cc..3da32df 100644 --- a/.changeset/experimental-lsp-hover.md +++ b/.changeset/experimental-lsp-hover.md @@ -2,4 +2,4 @@ "@flemma-dev/flemma.nvim": minor --- -Added experimental in-process LSP server for chat buffers with hover 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. This is the foundation for future LSP features like goto-definition for includes. +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/lua/flemma/lsp.lua b/lua/flemma/lsp.lua index 05fe0fc..853cfab 100644 --- a/lua/flemma/lsp.lua +++ b/lua/flemma/lsp.lua @@ -5,6 +5,7 @@ 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") @@ -257,6 +258,48 @@ local function handle_hover(params) 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 uri = params.textDocument.uri + local bufnr = vim.uri_to_bufnr(uri) + + log.debug("lsp definition: uri=" .. uri .. " -> bufnr=" .. bufnr) + + if not vim.api.nvim_buf_is_valid(bufnr) then + log.warn("lsp definition: buffer " .. bufnr .. " is invalid (uri=" .. uri .. ")") + return nil + end + + -- LSP positions are 0-indexed; navigation expects 1-indexed + local lnum = params.position.line + 1 + local col = params.position.character + 1 + + log.debug("lsp definition: resolving include at L" .. lnum .. ":C" .. col) + + 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 @@ -268,10 +311,11 @@ local function create_server(dispatchers) request = function(method, params, callback) log.trace("lsp server: request " .. method) if method == "initialize" then - log.debug("lsp server: initialize — advertising hoverProvider") + log.debug("lsp server: initialize — advertising hoverProvider, definitionProvider") callback(nil, { capabilities = { hoverProvider = true, + definitionProvider = true, }, }) return true, 1 @@ -285,10 +329,15 @@ local function create_server(dispatchers) 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, 4 + return true, 5 end end, 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/tests/flemma/lsp_spec.lua b/tests/flemma/lsp_spec.lua index e132f32..0a59a4b 100644 --- a/tests/flemma/lsp_spec.lua +++ b/tests/flemma/lsp_spec.lua @@ -152,6 +152,75 @@ describe("Flemma LSP", function() 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:", From b22430c8befb3738a2021af88b73d7b1f81b7895 Mon Sep 17 00:00:00 2001 From: Stan Angeloff Date: Thu, 12 Mar 2026 20:44:54 +0200 Subject: [PATCH 15/16] refactor(lsp): extract resolve_params helper and consolidate logging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DRY the repeated URI→bufnr validation and LSP→AST position conversion into a shared resolve_params() function. Consolidate two debug log lines per request into a single line using GitHub-style URI fragments (file:///...chat#L18C8 → bufnr=3). Co-Authored-By: Claude Opus 4.6 --- lua/flemma/lsp.lua | 62 +++++++++++++++++++--------------------------- 1 file changed, 26 insertions(+), 36 deletions(-) diff --git a/lua/flemma/lsp.lua b/lua/flemma/lsp.lua index 853cfab..336eb77 100644 --- a/lua/flemma/lsp.lua +++ b/lua/flemma/lsp.lua @@ -154,6 +154,28 @@ local function frontmatter_to_markdown(fm) 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 @@ -170,32 +192,11 @@ end ---@param params table LSP HoverParams ---@return table|nil result LSP Hover response or nil local function handle_hover(params) - local uri = params.textDocument.uri - local bufnr = vim.uri_to_bufnr(uri) - - log.debug("lsp hover: uri=" .. uri .. " -> bufnr=" .. bufnr) - - if not vim.api.nvim_buf_is_valid(bufnr) then - log.warn("lsp hover: buffer " .. bufnr .. " is invalid (uri=" .. uri .. ")") + local bufnr, lnum, col = resolve_params(params, "hover") + if not bufnr then return nil end - -- LSP positions are 0-indexed; AST positions are 1-indexed - local lnum = params.position.line + 1 - local col = params.position.character + 1 - - log.debug( - "lsp hover: position LSP(L" - .. params.position.line - .. ":C" - .. params.position.character - .. ") -> AST(L" - .. lnum - .. ":C" - .. col - .. ")" - ) - local doc = parser.get_parsed_document(bufnr) log.debug( @@ -263,22 +264,11 @@ end ---@param params table LSP DefinitionParams ---@return table|nil result LSP Location or nil local function handle_definition(params) - local uri = params.textDocument.uri - local bufnr = vim.uri_to_bufnr(uri) - - log.debug("lsp definition: uri=" .. uri .. " -> bufnr=" .. bufnr) - - if not vim.api.nvim_buf_is_valid(bufnr) then - log.warn("lsp definition: buffer " .. bufnr .. " is invalid (uri=" .. uri .. ")") + local bufnr, lnum, col = resolve_params(params, "definition") + if not bufnr then return nil end - -- LSP positions are 0-indexed; navigation expects 1-indexed - local lnum = params.position.line + 1 - local col = params.position.character + 1 - - log.debug("lsp definition: resolving include at L" .. lnum .. ":C" .. col) - local resolved_path = navigation.resolve_include_path(bufnr, lnum, col) if not resolved_path then log.debug("lsp definition: no include path resolved") From 3d5ff24d0b3c1465dec15c736176fd1c04326a66 Mon Sep 17 00:00:00 2001 From: Stan Angeloff Date: Thu, 12 Mar 2026 20:49:45 +0200 Subject: [PATCH 16/16] feat(config): default experimental.lsp to true when vim.lsp is available LSP hover and goto-definition are stable enough to enable by default. Falls back to false on Neovim builds without LSP support. Removes the now-redundant experimental override from the develop Makefile target. Co-Authored-By: Claude Opus 4.6 --- Makefile | 1 - lua/flemma/config.lua | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 40dd4cc..e5e8ed0 100644 --- a/Makefile +++ b/Makefile @@ -59,7 +59,6 @@ develop: " \ -c "lua \ require(\"flemma\").setup({ \ - experimental = { lsp = true }, \ model = \"\$$haiku\", \ parameters = { thinking = \"minimal\" }, \ presets = { \ diff --git a/lua/flemma/config.lua b/lua/flemma/config.lua index 45918bd..c71b3e9 100644 --- a/lua/flemma/config.lua +++ b/lua/flemma/config.lua @@ -353,6 +353,6 @@ return { }, }, experimental = { - lsp = false, + lsp = vim.lsp ~= nil, }, }