Skip to content

Commit 2412f95

Browse files
authored
feat: diagnostics tool implementation (#34)
* feat: complete implementation of getDiagnostics tool * refactor(getDiagnostics): remove pcall from logger import * refactor(getDiagnostics): use alternate json encode We're also passing nil explicitly to get diagnostics from all buffers * refactor(getDiagnostics): tweak file scheme handling, assume URI input * fix(getDiagnostics): remove Neovim context from description
1 parent da78309 commit 2412f95

File tree

2 files changed

+176
-31
lines changed

2 files changed

+176
-31
lines changed
Lines changed: 72 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,103 @@
11
--- Tool implementation for getting diagnostics.
22

3+
-- NOTE: Its important we don't tip off Claude that we're dealing with Neovim LSP diagnostics because it may adjust
4+
-- line and col numbers by 1 on its own (since it knows nvim LSP diagnostics are 0-indexed). By calling these
5+
-- "editor diagnostics" and converting to 1-indexed ourselves we (hopefully) avoid incorrect line and column numbers
6+
-- in Claude's responses.
7+
local schema = {
8+
description = "Get language diagnostics (errors, warnings) from the editor",
9+
inputSchema = {
10+
type = "object",
11+
properties = {
12+
uri = {
13+
type = "string",
14+
description = "Optional file URI to get diagnostics for. If not provided, gets diagnostics for all open files.",
15+
},
16+
},
17+
additionalProperties = false,
18+
["$schema"] = "http://json-schema.org/draft-07/schema#",
19+
},
20+
}
21+
322
--- Handles the getDiagnostics tool invocation.
423
-- Retrieves diagnostics from Neovim's diagnostic system.
5-
-- @param _params table The input parameters for the tool (currently unused).
24+
-- @param params table The input parameters for the tool.
25+
-- @field params.uri string|nil Optional file URI to get diagnostics for.
626
-- @return table A table containing the list of diagnostics.
727
-- @error table A table with code, message, and data for JSON-RPC error if failed.
8-
local function handler(_params) -- Prefix unused params with underscore
28+
local function handler(params)
929
if not vim.lsp or not vim.diagnostic or not vim.diagnostic.get then
10-
-- This tool is internal, so returning an error might be too strong.
1130
-- Returning an empty list or a specific status could be an alternative.
1231
-- For now, let's align with the error pattern for consistency if the feature is unavailable.
1332
error({
1433
code = -32000,
1534
message = "Feature unavailable",
16-
data = "LSP or vim.diagnostic.get not available in this Neovim version/configuration.",
35+
data = "Diagnostics not available in this editor version/configuration.",
1736
})
1837
end
1938

20-
local all_diagnostics = vim.diagnostic.get(0) -- Get for all buffers
39+
local logger = require("claudecode.logger")
40+
41+
logger.debug("getDiagnostics handler called with params: " .. vim.inspect(params))
42+
43+
-- Extract the uri parameter
44+
local diagnostics
45+
46+
if not params.uri then
47+
-- Get diagnostics for all buffers
48+
logger.debug("Getting diagnostics for all open buffers")
49+
diagnostics = vim.diagnostic.get(nil)
50+
else
51+
local uri = params.uri
52+
-- Strips the file:// scheme
53+
local filepath = vim.uri_to_fname(uri)
54+
55+
-- Get buffer number for the specific file
56+
local bufnr = vim.fn.bufnr(filepath)
57+
if bufnr == -1 then
58+
-- File is not open in any buffer, throw an error
59+
logger.debug("File buffer must be open to get diagnostics: " .. filepath)
60+
error({
61+
code = -32001,
62+
message = "File not open",
63+
data = "File must be open to retrieve diagnostics: " .. filepath,
64+
})
65+
else
66+
-- Get diagnostics for the specific buffer
67+
logger.debug("Getting diagnostics for bufnr: " .. bufnr)
68+
diagnostics = vim.diagnostic.get(bufnr)
69+
end
70+
end
2171

2272
local formatted_diagnostics = {}
23-
for _, diagnostic in ipairs(all_diagnostics) do
73+
for _, diagnostic in ipairs(diagnostics) do
2474
local file_path = vim.api.nvim_buf_get_name(diagnostic.bufnr)
2575
-- Ensure we only include diagnostics with valid file paths
2676
if file_path and file_path ~= "" then
2777
table.insert(formatted_diagnostics, {
28-
file = file_path,
29-
line = diagnostic.lnum, -- 0-indexed from vim.diagnostic.get
30-
character = diagnostic.col, -- 0-indexed from vim.diagnostic.get
31-
severity = diagnostic.severity, -- e.g., vim.diagnostic.severity.ERROR
32-
message = diagnostic.message,
33-
source = diagnostic.source,
78+
type = "text",
79+
-- json encode this
80+
text = vim.json.encode({
81+
-- Use the file path and diagnostic information
82+
filePath = file_path,
83+
-- Convert line and column to 1-indexed
84+
line = diagnostic.lnum + 1,
85+
character = diagnostic.col + 1,
86+
severity = diagnostic.severity, -- e.g., vim.diagnostic.severity.ERROR
87+
message = diagnostic.message,
88+
source = diagnostic.source,
89+
}),
3490
})
3591
end
3692
end
3793

38-
return { diagnostics = formatted_diagnostics }
94+
return {
95+
content = formatted_diagnostics,
96+
}
3997
end
4098

4199
return {
42100
name = "getDiagnostics",
43-
schema = nil, -- Internal tool
101+
schema = schema,
44102
handler = handler,
45103
}

tests/unit/tools/get_diagnostics_spec.lua

Lines changed: 104 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,23 @@ describe("Tool: get_diagnostics", function()
55

66
before_each(function()
77
package.loaded["claudecode.tools.get_diagnostics"] = nil
8+
package.loaded["claudecode.logger"] = nil
9+
10+
-- Mock the logger module
11+
package.loaded["claudecode.logger"] = {
12+
debug = function() end,
13+
error = function() end,
14+
info = function() end,
15+
warn = function() end,
16+
}
17+
818
get_diagnostics_handler = require("claudecode.tools.get_diagnostics").handler
919

1020
_G.vim = _G.vim or {}
1121
_G.vim.lsp = _G.vim.lsp or {} -- Ensure vim.lsp exists for the check
1222
_G.vim.diagnostic = _G.vim.diagnostic or {}
1323
_G.vim.api = _G.vim.api or {}
24+
_G.vim.fn = _G.vim.fn or {}
1425

1526
-- Default mocks
1627
_G.vim.diagnostic.get = spy.new(function()
@@ -19,12 +30,34 @@ describe("Tool: get_diagnostics", function()
1930
_G.vim.api.nvim_buf_get_name = spy.new(function(bufnr)
2031
return "/path/to/file_for_buf_" .. tostring(bufnr) .. ".lua"
2132
end)
33+
_G.vim.json.encode = spy.new(function(obj)
34+
return vim.inspect(obj) -- Use vim.inspect as a simple serialization
35+
end)
36+
_G.vim.fn.bufnr = spy.new(function(filepath)
37+
-- Mock buffer lookup
38+
if filepath == "/test/file.lua" then
39+
return 1
40+
end
41+
return -1 -- File not open
42+
end)
43+
_G.vim.uri_to_fname = spy.new(function(uri)
44+
-- Realistic mock that matches vim.uri_to_fname behavior
45+
if uri:sub(1, 7) == "file://" then
46+
return uri:sub(8)
47+
end
48+
-- Real vim.uri_to_fname throws an error for URIs without proper scheme
49+
error("URI must contain a scheme: " .. uri)
50+
end)
2251
end)
2352

2453
after_each(function()
2554
package.loaded["claudecode.tools.get_diagnostics"] = nil
55+
package.loaded["claudecode.logger"] = nil
2656
_G.vim.diagnostic.get = nil
2757
_G.vim.api.nvim_buf_get_name = nil
58+
_G.vim.json.encode = nil
59+
_G.vim.fn.bufnr = nil
60+
_G.vim.uri_to_fname = nil
2861
-- Note: We don't nullify _G.vim.lsp or _G.vim.diagnostic entirely
2962
-- as they are checked for existence.
3063
end)
@@ -33,9 +66,9 @@ describe("Tool: get_diagnostics", function()
3366
local success, result = pcall(get_diagnostics_handler, {})
3467
expect(success).to_be_true()
3568
expect(result).to_be_table()
36-
expect(result.diagnostics).to_be_table()
37-
expect(#result.diagnostics).to_be(0)
38-
assert.spy(_G.vim.diagnostic.get).was_called_with(0)
69+
expect(result.content).to_be_table()
70+
expect(#result.content).to_be(0)
71+
assert.spy(_G.vim.diagnostic.get).was_called_with(nil)
3972
end)
4073

4174
it("should return formatted diagnostics if available", function()
@@ -49,19 +82,24 @@ describe("Tool: get_diagnostics", function()
4982

5083
local success, result = pcall(get_diagnostics_handler, {})
5184
expect(success).to_be_true()
52-
expect(result.diagnostics).to_be_table()
53-
expect(#result.diagnostics).to_be(2)
85+
expect(result.content).to_be_table()
86+
expect(#result.content).to_be(2)
87+
88+
-- Check that results are MCP content items
89+
expect(result.content[1].type).to_be("text")
90+
expect(result.content[2].type).to_be("text")
5491

55-
expect(result.diagnostics[1].file).to_be("/path/to/file_for_buf_1.lua")
56-
expect(result.diagnostics[1].line).to_be(10)
57-
expect(result.diagnostics[1].character).to_be(5)
58-
expect(result.diagnostics[1].severity).to_be(1)
59-
expect(result.diagnostics[1].message).to_be("Error message 1")
60-
expect(result.diagnostics[1].source).to_be("linter1")
92+
-- Verify JSON encoding was called with correct structure
93+
assert.spy(_G.vim.json.encode).was_called(2)
6194

62-
expect(result.diagnostics[2].file).to_be("/path/to/file_for_buf_2.lua")
63-
expect(result.diagnostics[2].severity).to_be(2)
64-
expect(result.diagnostics[2].message).to_be("Warning message 2")
95+
-- Check the first diagnostic was encoded with 1-indexed values
96+
local first_call_args = _G.vim.json.encode.calls[1].vals[1]
97+
expect(first_call_args.filePath).to_be("/path/to/file_for_buf_1.lua")
98+
expect(first_call_args.line).to_be(11) -- 10 + 1 for 1-indexing
99+
expect(first_call_args.character).to_be(6) -- 5 + 1 for 1-indexing
100+
expect(first_call_args.severity).to_be(1)
101+
expect(first_call_args.message).to_be("Error message 1")
102+
expect(first_call_args.source).to_be("linter1")
65103

66104
assert.spy(_G.vim.api.nvim_buf_get_name).was_called_with(1)
67105
assert.spy(_G.vim.api.nvim_buf_get_name).was_called_with(2)
@@ -87,8 +125,12 @@ describe("Tool: get_diagnostics", function()
87125

88126
local success, result = pcall(get_diagnostics_handler, {})
89127
expect(success).to_be_true()
90-
expect(#result.diagnostics).to_be(1)
91-
expect(result.diagnostics[1].file).to_be("/path/to/file1.lua")
128+
expect(#result.content).to_be(1)
129+
130+
-- Verify only the diagnostic with a file path was included
131+
assert.spy(_G.vim.json.encode).was_called(1)
132+
local encoded_args = _G.vim.json.encode.calls[1].vals[1]
133+
expect(encoded_args.filePath).to_be("/path/to/file1.lua")
92134
end)
93135

94136
it("should error if vim.diagnostic.get is not available", function()
@@ -98,7 +140,7 @@ describe("Tool: get_diagnostics", function()
98140
expect(err).to_be_table()
99141
expect(err.code).to_be(-32000)
100142
assert_contains(err.message, "Feature unavailable")
101-
assert_contains(err.data, "LSP or vim.diagnostic.get not available")
143+
assert_contains(err.data, "Diagnostics not available in this editor version/configuration.")
102144
end)
103145

104146
it("should error if vim.diagnostic is not available", function()
@@ -120,4 +162,49 @@ describe("Tool: get_diagnostics", function()
120162
expect(success).to_be_false()
121163
expect(err.code).to_be(-32000)
122164
end)
165+
166+
it("should filter diagnostics by URI when provided", function()
167+
local mock_diagnostics = {
168+
{ bufnr = 1, lnum = 10, col = 5, severity = 1, message = "Error in file1", source = "linter1" },
169+
}
170+
_G.vim.diagnostic.get = spy.new(function(bufnr)
171+
if bufnr == 1 then
172+
return mock_diagnostics
173+
end
174+
return {}
175+
end)
176+
_G.vim.api.nvim_buf_get_name = spy.new(function(bufnr)
177+
if bufnr == 1 then
178+
return "/test/file.lua"
179+
end
180+
return ""
181+
end)
182+
183+
local success, result = pcall(get_diagnostics_handler, { uri = "file:///test/file.lua" })
184+
expect(success).to_be_true()
185+
expect(#result.content).to_be(1)
186+
187+
-- Should have used vim.uri_to_fname to convert URI to file path
188+
assert.spy(_G.vim.uri_to_fname).was_called_with("file:///test/file.lua")
189+
assert.spy(_G.vim.diagnostic.get).was_called_with(1)
190+
assert.spy(_G.vim.fn.bufnr).was_called_with("/test/file.lua")
191+
end)
192+
193+
it("should error for URI of unopened file", function()
194+
_G.vim.fn.bufnr = spy.new(function()
195+
return -1 -- File not open
196+
end)
197+
198+
local success, err = pcall(get_diagnostics_handler, { uri = "file:///unknown/file.lua" })
199+
expect(success).to_be_false()
200+
expect(err).to_be_table()
201+
expect(err.code).to_be(-32001)
202+
expect(err.message).to_be("File not open")
203+
assert_contains(err.data, "File must be open to retrieve diagnostics: /unknown/file.lua")
204+
205+
-- Should have used vim.uri_to_fname and checked for buffer but not called vim.diagnostic.get
206+
assert.spy(_G.vim.uri_to_fname).was_called_with("file:///unknown/file.lua")
207+
assert.spy(_G.vim.fn.bufnr).was_called_with("/unknown/file.lua")
208+
assert.spy(_G.vim.diagnostic.get).was_not_called()
209+
end)
123210
end)

0 commit comments

Comments
 (0)