Skip to content

Commit 116339b

Browse files
olisikhs1n7ax
andauthored
fix(classpath): fix bug when generated sources are skipped by jdtls (#489)
* fix(classpath): fix bug when generated sources are not considered by jdtls * fix(classpath): code review remarks addressed * chore(format): fix stylua formatting * fix(classpath): normalize path separators when matching generated source roots on Windows * fix(classpath): normalize before stripping root prefix in get_relative_path --------- Co-authored-by: s1n7ax <srineshnisala@gmail.com>
1 parent 3f071ca commit 116339b

4 files changed

Lines changed: 429 additions & 0 deletions

File tree

lua/java.lua

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,10 @@ function M.setup(custom_config)
7979
----------------------------------------------------------------------
8080
-- init --
8181
----------------------------------------------------------------------
82+
if config.experimental.fix_generated_sources then
83+
require('java.experimental.fix-generated-sources').patch(vim.fn.getcwd())
84+
end
85+
8286
require('java.startup.lsp_setup').setup(config)
8387
require('java.startup.decompile-watcher').setup()
8488
require('java-refactor').setup()

lua/java/config.lua

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ local V = jdtls_version_map[JDTLS_VERSION]
2727
---@field java_debug_adapter { enable: boolean, version: string }
2828
---@field spring_boot_tools { enable: boolean, version: string }
2929
---@field jdk { auto_install: boolean, version: string }
30+
---@field experimental { fix_generated_sources: boolean }
3031
---@field log java-core.Log2Config
3132

3233
---@class java.PartialConfig
@@ -37,6 +38,7 @@ local V = jdtls_version_map[JDTLS_VERSION]
3738
---@field java_debug_adapter? { enable?: boolean, version?: string }
3839
---@field spring_boot_tools? { enable?: boolean, version?: string }
3940
---@field jdk? { auto_install?: boolean, version?: string }
41+
---@field experimental? { fix_generated_sources?: boolean }
4042
---@field log? java-core.PartialLog2Config
4143

4244
---@type java.Config
@@ -46,6 +48,10 @@ local config = {
4648
nvim_jdtls_conflict = true,
4749
},
4850

51+
experimental = {
52+
fix_generated_sources = false,
53+
},
54+
4955
jdtls = {
5056
version = JDTLS_VERSION,
5157
},
Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
local path_utils = require('java-core.utils.path')
2+
local log = require('java-core.utils.log2')
3+
4+
local M = {}
5+
6+
---Normalize path separators to `/` for .classpath operations.
7+
---Eclipse .classpath files always use `/` regardless of OS.
8+
---@param p string
9+
---@return string
10+
local function classpath_normalize(p)
11+
return p:gsub('\\', '/')
12+
end
13+
14+
---Set or update the `excluding` attribute on a classpathentry line.
15+
---Parses any existing exclusions, merges with the new set (union),
16+
---and writes the sorted result back.
17+
---@param line string
18+
---@param exclusions string[]
19+
---@return string
20+
local function set_exclusions(line, exclusions)
21+
-- Parse existing exclusions from the line
22+
local existing = {}
23+
local existing_match = line:match('excluding="([^"]+)"')
24+
if existing_match then
25+
for entry in existing_match:gmatch('[^|]+') do
26+
existing[entry] = true
27+
end
28+
end
29+
30+
-- Merge: union existing + new (new keys take no precedence — it's a set union)
31+
local merged = vim.deepcopy(existing)
32+
for _, excl in ipairs(exclusions) do
33+
merged[excl] = true
34+
end
35+
36+
local merged_list = vim.tbl_keys(merged)
37+
table.sort(merged_list)
38+
local exclusion_value = table.concat(merged_list, '|')
39+
40+
if existing_match then
41+
return line:gsub('excluding="[^"]+"', 'excluding="' .. exclusion_value .. '"', 1)
42+
end
43+
44+
return line:gsub('<classpathentry ', '<classpathentry excluding="' .. exclusion_value .. '" ', 1)
45+
end
46+
47+
---Get the relative path from root. Result is normalized to `/`
48+
---for .classpath compatibility.
49+
---@param root string
50+
---@param absolute_path string
51+
---@return string
52+
local function get_relative_path(root, absolute_path)
53+
-- vim.fs.find always joins children with '/' regardless of OS, so
54+
-- normalize both sides before stripping the prefix.
55+
local normalized_root = classpath_normalize(root)
56+
local normalized_path = classpath_normalize(absolute_path)
57+
local prefix = normalized_root .. '/'
58+
if normalized_path:sub(1, #prefix) == prefix then
59+
return normalized_path:sub(#prefix + 1)
60+
end
61+
return normalized_path
62+
end
63+
64+
---@param lines string[]
65+
---@param entry_path string
66+
---@return boolean
67+
local function has_classpath_entry(lines, entry_path)
68+
local pattern = 'path="' .. entry_path .. '"'
69+
for _, line in ipairs(lines) do
70+
if line:find(pattern, 1, true) then
71+
return true
72+
end
73+
end
74+
return false
75+
end
76+
77+
---Find the index of the target/generated-sources classpathentry.
78+
---Assumes .classpath uses double or single quotes and one tag per line.
79+
---@param lines string[]
80+
---@return integer|nil
81+
local function find_generated_sources_entry(lines)
82+
for index, line in ipairs(lines) do
83+
if line:find('path="target/generated%-sources"') or line:find("path='target/generated%-sources'") then
84+
return index
85+
end
86+
end
87+
return nil
88+
end
89+
90+
---Insert a new classpathentry before the <classpathentry kind="output"> line.
91+
---@param lines string[]
92+
---@param entry_path string
93+
local function add_classpath_entry(lines, entry_path)
94+
local entry = {
95+
'\t<classpathentry kind="src" output="target/classes" path="' .. entry_path .. '">',
96+
'\t\t<attributes>',
97+
'\t\t\t<attribute name="optional" value="true"/>',
98+
'\t\t\t<attribute name="maven.pomderived" value="true"/>',
99+
'\t\t</attributes>',
100+
'\t</classpathentry>',
101+
}
102+
103+
local output_index = #lines + 1
104+
for index, line in ipairs(lines) do
105+
if line:find('<classpathentry kind="output"') then
106+
output_index = index
107+
break
108+
end
109+
end
110+
111+
for offset = #entry, 1, -1 do
112+
table.insert(lines, output_index, entry[offset])
113+
end
114+
end
115+
116+
---Find generated source roots under target/generated-sources that
117+
---match the pattern `*/src/{segment}/java`.
118+
---@param module_root string
119+
---@return string[]
120+
local function get_generated_source_roots(module_root)
121+
local generated_root = path_utils.join(module_root, 'target', 'generated-sources')
122+
if not vim.uv.fs_stat(generated_root) then
123+
return {}
124+
end
125+
126+
-- vim.fs.find always yields '/'-separated paths regardless of OS, so
127+
-- match against a '/'-normalized substring even on Windows.
128+
local target_gen_src = 'target/generated-sources'
129+
local java_roots = vim.fs.find(function(name, generated_path)
130+
return name == 'java' and classpath_normalize(generated_path):find(target_gen_src, 1, true) ~= nil
131+
end, {
132+
path = generated_root,
133+
type = 'directory',
134+
limit = 500,
135+
})
136+
137+
local source_roots = {}
138+
local seen_roots = {}
139+
for _, java_root in ipairs(java_roots) do
140+
local relative_to_generated_root = get_relative_path(generated_root, java_root)
141+
-- Split on / since get_relative_path normalizes
142+
local segments = vim.split(relative_to_generated_root, '/', { plain = true })
143+
local segment_count = #segments
144+
145+
if segment_count >= 3 and segments[segment_count] == 'java' and segments[segment_count - 2] == 'src' then
146+
local source_root = get_relative_path(module_root, java_root)
147+
if not seen_roots[source_root] then
148+
seen_roots[source_root] = true
149+
table.insert(source_roots, source_root)
150+
end
151+
end
152+
end
153+
154+
table.sort(source_roots)
155+
return source_roots
156+
end
157+
158+
---Build exclusion list for the parent target/generated-sources entry.
159+
---Always seeds `annotations/` — Eclipse/Maven convention excludes the
160+
---annotations subdirectory from generated-sources to avoid processing
161+
---compiled annotation processor output as source.
162+
---@param source_roots string[]
163+
---@return string[]
164+
local function get_generated_source_exclusions(source_roots)
165+
local exclusions = { ['annotations/'] = true }
166+
-- Normalize prefix to / for comparison with source_roots (which are /-normalized)
167+
local generated_root_prefix = classpath_normalize(path_utils.join('target', 'generated-sources')) .. '/'
168+
169+
for _, source_root in ipairs(source_roots) do
170+
if source_root:sub(1, #generated_root_prefix) == generated_root_prefix then
171+
local relative_to_generated_root = source_root:sub(#generated_root_prefix + 1)
172+
local first_segment = vim.split(relative_to_generated_root, '/', { plain = true })[1]
173+
if first_segment then
174+
exclusions[first_segment .. '/'] = true
175+
end
176+
end
177+
end
178+
179+
local ordered_exclusions = vim.tbl_keys(exclusions)
180+
table.sort(ordered_exclusions)
181+
return ordered_exclusions
182+
end
183+
184+
---Patch a single .classpath file to include generated source roots
185+
---and exclude them from the parent generated-sources entry.
186+
---Safe to re-run — idempotent (has_classpath_entry + set_exclusions
187+
---no-op when already applied).
188+
---@param classpath_file string
189+
---@return boolean changed
190+
local function patch_module_classpath(classpath_file)
191+
local module_root = vim.fs.dirname(classpath_file)
192+
local lines = vim.fn.readfile(classpath_file)
193+
local generated_sources_entry_index = find_generated_sources_entry(lines)
194+
if not generated_sources_entry_index then
195+
return false
196+
end
197+
198+
local file_changed = false
199+
local source_roots = get_generated_source_roots(module_root)
200+
local line = lines[generated_sources_entry_index]
201+
local patched = set_exclusions(line, get_generated_source_exclusions(source_roots))
202+
203+
if patched ~= line then
204+
lines[generated_sources_entry_index] = patched
205+
file_changed = true
206+
end
207+
208+
for _, source_root in ipairs(source_roots) do
209+
if not has_classpath_entry(lines, source_root) then
210+
add_classpath_entry(lines, source_root)
211+
file_changed = true
212+
end
213+
end
214+
215+
if file_changed then
216+
log.info('nvim-java: patching .classpath with generated sources', classpath_file)
217+
vim.fn.writefile(lines, classpath_file)
218+
end
219+
220+
return file_changed
221+
end
222+
223+
---Patch all .classpath files under `root` to include generated source roots.
224+
---This is an experimental workaround for JDTLS import failures caused by
225+
---nested generated sources under target/generated-sources.
226+
---@param root string # Project root directory
227+
---@return boolean changed # true if any .classpath file was modified
228+
function M.patch(root)
229+
local changed = false
230+
local start = vim.uv.hrtime()
231+
local count = 0
232+
local LIMIT = 500
233+
234+
for _, file in ipairs(vim.fs.find('.classpath', { path = root, type = 'file', limit = LIMIT })) do
235+
if patch_module_classpath(file) then
236+
changed = true
237+
end
238+
count = count + 1
239+
end
240+
241+
if count >= LIMIT then
242+
log.warn(
243+
'nvim-java: fix_generated_sources hit .classpath find limit (' .. LIMIT .. ') — some files may be missed'
244+
)
245+
end
246+
247+
local elapsed = (vim.uv.hrtime() - start) / 1e6
248+
log.debug(('nvim-java: fix_generated_sources scanned %d .classpath files in %.2fms'):format(count, elapsed))
249+
250+
return changed
251+
end
252+
253+
return M

0 commit comments

Comments
 (0)