From a70a343d8cd523cf96da26a52f8facad69c92a94 Mon Sep 17 00:00:00 2001 From: Oleksii Lisikh Date: Wed, 20 May 2026 15:36:57 +0200 Subject: [PATCH 1/2] fix(classpath): fix bug when generated sources are not considered by jdtls --- lua/java.lua | 4 + lua/java/config.lua | 6 + .../experimental/fix-generated-sources.lua | 165 ++++++++++++++++++ .../generated_sources_classpath_spec.lua | 79 +++++++++ 4 files changed, 254 insertions(+) create mode 100644 lua/java/experimental/fix-generated-sources.lua create mode 100644 tests/specs/generated_sources_classpath_spec.lua diff --git a/lua/java.lua b/lua/java.lua index 59332f3..5e8ba50 100644 --- a/lua/java.lua +++ b/lua/java.lua @@ -79,6 +79,10 @@ function M.setup(custom_config) ---------------------------------------------------------------------- -- init -- ---------------------------------------------------------------------- + if config.experimental.fix_generated_sources then + require('java.experimental.fix-generated-sources').patch(vim.fn.getcwd()) + end + require('java.startup.lsp_setup').setup(config) require('java.startup.decompile-watcher').setup() require('java-refactor').setup() diff --git a/lua/java/config.lua b/lua/java/config.lua index 8b65edc..bd5c32b 100644 --- a/lua/java/config.lua +++ b/lua/java/config.lua @@ -27,6 +27,7 @@ local V = jdtls_version_map[JDTLS_VERSION] ---@field java_debug_adapter { enable: boolean, version: string } ---@field spring_boot_tools { enable: boolean, version: string } ---@field jdk { auto_install: boolean, version: string } +---@field experimental { fix_generated_sources: boolean } ---@field log java-core.Log2Config ---@class java.PartialConfig @@ -37,6 +38,7 @@ local V = jdtls_version_map[JDTLS_VERSION] ---@field java_debug_adapter? { enable?: boolean, version?: string } ---@field spring_boot_tools? { enable?: boolean, version?: string } ---@field jdk? { auto_install?: boolean, version?: string } +---@field experimental? { fix_generated_sources?: boolean } ---@field log? java-core.PartialLog2Config ---@type java.Config @@ -46,6 +48,10 @@ local config = { nvim_jdtls_conflict = true, }, + experimental = { + fix_generated_sources = false, + }, + jdtls = { version = JDTLS_VERSION, }, diff --git a/lua/java/experimental/fix-generated-sources.lua b/lua/java/experimental/fix-generated-sources.lua new file mode 100644 index 0000000..04e6136 --- /dev/null +++ b/lua/java/experimental/fix-generated-sources.lua @@ -0,0 +1,165 @@ +local path_utils = require('java-core.utils.path') + +local M = {} + +local function set_exclusions(line, exclusions) + local exclusion_value = table.concat(exclusions, '|') + if line:match('excluding="[^"]+"') then + return line:gsub('excluding="([^"]+)"', 'excluding="' .. exclusion_value .. '"', 1) + end + + return line:gsub('', + '\t\t', + '\t\t\t', + '\t\t\t', + '\t\t', + '\t', + } + + local output_index = #lines + 1 + for index, line in ipairs(lines) do + if line:find('= 3 and segments[segment_count] == 'java' and segments[segment_count - 2] == 'src' then + local source_root = get_relative_path(module_root, java_root) + if not seen_roots[source_root] then + seen_roots[source_root] = true + table.insert(source_roots, source_root) + end + end + end + + table.sort(source_roots) + return source_roots +end + +local function get_generated_source_exclusions(source_roots) + local exclusions = { ['annotations/'] = true } + local generated_root_prefix = path_utils.join('target', 'generated-sources') .. path_utils.path_separator + + for _, source_root in ipairs(source_roots) do + if source_root:sub(1, #generated_root_prefix) == generated_root_prefix then + local relative_to_generated_root = source_root:sub(#generated_root_prefix + 1) + local first_segment = vim.split(relative_to_generated_root, path_utils.path_separator, { plain = true })[1] + if first_segment then + exclusions[first_segment .. '/'] = true + end + end + end + + local ordered_exclusions = vim.tbl_keys(exclusions) + table.sort(ordered_exclusions) + return ordered_exclusions +end + +local function patch_module_classpath(classpath_file) + local module_root = vim.fs.dirname(classpath_file) + local lines = vim.fn.readfile(classpath_file) + local generated_sources_entry_index = find_generated_sources_entry(lines) + if not generated_sources_entry_index then + return false + end + + local file_changed = false + local source_roots = get_generated_source_roots(module_root) + local line = lines[generated_sources_entry_index] + local patched = set_exclusions(line, get_generated_source_exclusions(source_roots)) + + if patched ~= line then + lines[generated_sources_entry_index] = patched + file_changed = true + end + + for _, source_root in ipairs(source_roots) do + if not has_classpath_entry(lines, source_root) then + add_classpath_entry(lines, source_root) + file_changed = true + end + end + + if file_changed then + vim.fn.writefile(lines, classpath_file) + end + + return file_changed +end + +function M.patch(root) + local changed = false + for _, file in ipairs(vim.fs.find('.classpath', { path = root, type = 'file', limit = math.huge })) do + if patch_module_classpath(file) then + changed = true + end + end + + return changed +end + +return M diff --git a/tests/specs/generated_sources_classpath_spec.lua b/tests/specs/generated_sources_classpath_spec.lua new file mode 100644 index 0000000..6e59377 --- /dev/null +++ b/tests/specs/generated_sources_classpath_spec.lua @@ -0,0 +1,79 @@ +local assert = require('luassert') + +describe('Generated sources classpath patcher', function() + local classpath = require('java.experimental.fix-generated-sources') + local path = require('java-core.utils.path') + + local temp_dir + local module_root + local classpath_file + + before_each(function() + temp_dir = vim.fn.tempname() + module_root = path.join(temp_dir, 'project') + classpath_file = path.join(module_root, '.classpath') + + vim.fn.mkdir(path.join(module_root, 'target', 'generated-sources', 'src', 'main', 'java'), 'p') + vim.fn.mkdir(path.join(module_root, 'target', 'generated-sources', 'openapi', 'src', 'main', 'java'), 'p') + vim.fn.mkdir(path.join(module_root, 'target', 'generated-sources', 'foo', 'main', 'java'), 'p') + vim.fn.mkdir(path.join(module_root, 'target', 'generated-sources', 'openapi', 'main', 'java'), 'p') + vim.fn.mkdir(path.join(module_root, 'target', 'generated-sources', 'java'), 'p') + + vim.fn.writefile({ + '', + '', + '\t', + '\t\t', + '\t\t\t', + '\t\t\t', + '\t\t', + '\t', + '\t', + '\t\t', + '\t\t\t', + '\t\t\t', + '\t\t', + '\t', + '\t', + '', + }, classpath_file) + end) + + after_each(function() + vim.fn.delete(temp_dir, 'rf') + end) + + it('adds only generated roots that end with src/{segment}/java and excludes them from the parent root', function() + assert.is_true(classpath.patch(temp_dir)) + + local lines = vim.fn.readfile(classpath_file) + local content = table.concat(lines, '\n') + + assert.is_truthy(content:find('excluding="annotations/|openapi/|src/"', 1, true)) + assert.is_truthy(content:find('path="target/generated-sources/src/main/java"', 1, true)) + assert.is_truthy(content:find('path="target/generated-sources/openapi/src/main/java"', 1, true)) + assert.is_falsy(content:find('path="target/generated-sources/foo/main/java"', 1, true)) + assert.is_falsy(content:find('path="target/generated-sources/openapi/main/java"', 1, true)) + assert.is_falsy(content:find('path="target/generated-sources/java"', 1, true)) + end) + + it('does not add generated roots when the parent generated-sources entry is missing', function() + vim.fn.writefile({ + '', + '', + '\t', + '\t\t', + '\t\t\t', + '\t\t\t', + '\t\t', + '\t', + '\t', + '', + }, classpath_file) + + assert.is_false(classpath.patch(temp_dir)) + + local content = table.concat(vim.fn.readfile(classpath_file), '\n') + assert.is_falsy(content:find('target/generated-sources/src/main/java', 1, true)) + end) +end) From ce596639263d3aff59ab8156f882268d24979be5 Mon Sep 17 00:00:00 2001 From: Oleksii Lisikh Date: Mon, 8 Jun 2026 18:37:05 +0200 Subject: [PATCH 2/2] fix(classpath): code review remarks addressed --- .../experimental/fix-generated-sources.lua | 125 +++++++++++++++--- .../generated_sources_classpath_spec.lua | 101 +++++++++++++- 2 files changed, 200 insertions(+), 26 deletions(-) diff --git a/lua/java/experimental/fix-generated-sources.lua b/lua/java/experimental/fix-generated-sources.lua index 04e6136..744a6f2 100644 --- a/lua/java/experimental/fix-generated-sources.lua +++ b/lua/java/experimental/fix-generated-sources.lua @@ -1,46 +1,97 @@ local path_utils = require('java-core.utils.path') +local log = require('java-core.utils.log2') local M = {} +---Normalize path separators to `/` for .classpath operations. +---Eclipse .classpath files always use `/` regardless of OS. +---@param p string +---@return string +local function classpath_normalize(p) + return p:gsub('\\', '/') +end + +---Set or update the `excluding` attribute on a classpathentry line. +---Parses any existing exclusions, merges with the new set (union), +---and writes the sorted result back. +---@param line string +---@param exclusions string[] +---@return string local function set_exclusions(line, exclusions) - local exclusion_value = table.concat(exclusions, '|') - if line:match('excluding="[^"]+"') then - return line:gsub('excluding="([^"]+)"', 'excluding="' .. exclusion_value .. '"', 1) + -- Parse existing exclusions from the line + local existing = {} + local existing_match = line:match('excluding="([^"]+)"') + if existing_match then + for entry in existing_match:gmatch('[^|]+') do + existing[entry] = true + end + end + + -- Merge: union existing + new (new keys take no precedence — it's a set union) + local merged = vim.deepcopy(existing) + for _, excl in ipairs(exclusions) do + merged[excl] = true + end + + local merged_list = vim.tbl_keys(merged) + table.sort(merged_list) + local exclusion_value = table.concat(merged_list, '|') + + if existing_match then + return line:gsub('excluding="[^"]+"', 'excluding="' .. exclusion_value .. '"', 1) end return line:gsub(' line. +---@param lines string[] +---@param entry_path string local function add_classpath_entry(lines, entry_path) local entry = { '\t', @@ -62,29 +113,34 @@ local function add_classpath_entry(lines, entry_path) for offset = #entry, 1, -1 do table.insert(lines, output_index, entry[offset]) end - - return true end +---Find generated source roots under target/generated-sources that +---match the pattern `*/src/{segment}/java`. +---@param module_root string +---@return string[] local function get_generated_source_roots(module_root) local generated_root = path_utils.join(module_root, 'target', 'generated-sources') if not vim.uv.fs_stat(generated_root) then return {} end + -- Match 'target/generated-sources' substring using OS-native path separators + local target_gen_src = path_utils.join('target', 'generated-sources') local java_roots = vim.fs.find(function(name, generated_path) - return name == 'java' and generated_path:find(vim.pesc(path_utils.join('target', 'generated-sources')), 1, false) ~= nil + return name == 'java' and generated_path:find(target_gen_src, 1, true) ~= nil end, { path = generated_root, type = 'directory', - limit = math.huge, + limit = 500, }) local source_roots = {} local seen_roots = {} for _, java_root in ipairs(java_roots) do local relative_to_generated_root = get_relative_path(generated_root, java_root) - local segments = vim.split(relative_to_generated_root, path_utils.path_separator, { plain = true }) + -- Split on / since get_relative_path normalizes + local segments = vim.split(relative_to_generated_root, '/', { plain = true }) local segment_count = #segments if segment_count >= 3 and segments[segment_count] == 'java' and segments[segment_count - 2] == 'src' then @@ -100,14 +156,21 @@ local function get_generated_source_roots(module_root) return source_roots end +---Build exclusion list for the parent target/generated-sources entry. +---Always seeds `annotations/` — Eclipse/Maven convention excludes the +---annotations subdirectory from generated-sources to avoid processing +---compiled annotation processor output as source. +---@param source_roots string[] +---@return string[] local function get_generated_source_exclusions(source_roots) local exclusions = { ['annotations/'] = true } - local generated_root_prefix = path_utils.join('target', 'generated-sources') .. path_utils.path_separator + -- Normalize prefix to / for comparison with source_roots (which are /-normalized) + local generated_root_prefix = classpath_normalize(path_utils.join('target', 'generated-sources')) .. '/' for _, source_root in ipairs(source_roots) do if source_root:sub(1, #generated_root_prefix) == generated_root_prefix then local relative_to_generated_root = source_root:sub(#generated_root_prefix + 1) - local first_segment = vim.split(relative_to_generated_root, path_utils.path_separator, { plain = true })[1] + local first_segment = vim.split(relative_to_generated_root, '/', { plain = true })[1] if first_segment then exclusions[first_segment .. '/'] = true end @@ -119,6 +182,12 @@ local function get_generated_source_exclusions(source_roots) return ordered_exclusions end +---Patch a single .classpath file to include generated source roots +---and exclude them from the parent generated-sources entry. +---Safe to re-run — idempotent (has_classpath_entry + set_exclusions +---no-op when already applied). +---@param classpath_file string +---@return boolean changed local function patch_module_classpath(classpath_file) local module_root = vim.fs.dirname(classpath_file) local lines = vim.fn.readfile(classpath_file) @@ -145,20 +214,38 @@ local function patch_module_classpath(classpath_file) end if file_changed then + log.info('nvim-java: patching .classpath with generated sources', classpath_file) vim.fn.writefile(lines, classpath_file) end return file_changed end +---Patch all .classpath files under `root` to include generated source roots. +---This is an experimental workaround for JDTLS import failures caused by +---nested generated sources under target/generated-sources. +---@param root string # Project root directory +---@return boolean changed # true if any .classpath file was modified function M.patch(root) local changed = false - for _, file in ipairs(vim.fs.find('.classpath', { path = root, type = 'file', limit = math.huge })) do + local start = vim.uv.hrtime() + local count = 0 + local LIMIT = 500 + + for _, file in ipairs(vim.fs.find('.classpath', { path = root, type = 'file', limit = LIMIT })) do if patch_module_classpath(file) then changed = true end + count = count + 1 + end + + if count >= LIMIT then + log.warn('nvim-java: fix_generated_sources hit .classpath find limit (' .. LIMIT .. ') — some files may be missed') end + local elapsed = (vim.uv.hrtime() - start) / 1e6 + log.debug(('nvim-java: fix_generated_sources scanned %d .classpath files in %.2fms'):format(count, elapsed)) + return changed end diff --git a/tests/specs/generated_sources_classpath_spec.lua b/tests/specs/generated_sources_classpath_spec.lua index 6e59377..b474162 100644 --- a/tests/specs/generated_sources_classpath_spec.lua +++ b/tests/specs/generated_sources_classpath_spec.lua @@ -8,6 +8,14 @@ describe('Generated sources classpath patcher', function() local module_root local classpath_file + local function write_classpath(lines) + vim.fn.writefile(lines, classpath_file) + end + + local function read_classpath() + return table.concat(vim.fn.readfile(classpath_file), '\n') + end + before_each(function() temp_dir = vim.fn.tempname() module_root = path.join(temp_dir, 'project') @@ -19,7 +27,7 @@ describe('Generated sources classpath patcher', function() vim.fn.mkdir(path.join(module_root, 'target', 'generated-sources', 'openapi', 'main', 'java'), 'p') vim.fn.mkdir(path.join(module_root, 'target', 'generated-sources', 'java'), 'p') - vim.fn.writefile({ + write_classpath({ '', '', '\t', @@ -36,7 +44,7 @@ describe('Generated sources classpath patcher', function() '\t', '\t', '', - }, classpath_file) + }) end) after_each(function() @@ -46,8 +54,7 @@ describe('Generated sources classpath patcher', function() it('adds only generated roots that end with src/{segment}/java and excludes them from the parent root', function() assert.is_true(classpath.patch(temp_dir)) - local lines = vim.fn.readfile(classpath_file) - local content = table.concat(lines, '\n') + local content = read_classpath() assert.is_truthy(content:find('excluding="annotations/|openapi/|src/"', 1, true)) assert.is_truthy(content:find('path="target/generated-sources/src/main/java"', 1, true)) @@ -58,7 +65,7 @@ describe('Generated sources classpath patcher', function() end) it('does not add generated roots when the parent generated-sources entry is missing', function() - vim.fn.writefile({ + write_classpath({ '', '', '\t', @@ -69,11 +76,91 @@ describe('Generated sources classpath patcher', function() '\t', '\t', '', - }, classpath_file) + }) assert.is_false(classpath.patch(temp_dir)) - local content = table.concat(vim.fn.readfile(classpath_file), '\n') + local content = read_classpath() assert.is_falsy(content:find('target/generated-sources/src/main/java', 1, true)) end) + + it('is idempotent — second call returns false and leaves file unchanged', function() + assert.is_true(classpath.patch(temp_dir)) + local after_first = read_classpath() + + assert.is_false(classpath.patch(temp_dir)) + local after_second = read_classpath() + + assert.equals(after_first, after_second) + end) + + it('preserves pre-existing exclusions in the generated-sources entry', function() + write_classpath({ + '', + '', + '\t', + '\t\t', + '\t\t\t', + '\t\t\t', + '\t\t', + '\t', + '\t', + '\t\t', + '\t\t\t', + '\t\t\t', + '\t\t', + '\t', + '\t', + '', + }) + + assert.is_true(classpath.patch(temp_dir)) + + local content = read_classpath() + -- Original exclusions are preserved + assert.is_truthy(content:find('custom/', 1, true)) + assert.is_truthy(content:find('other/', 1, true)) + -- New exclusions are added + assert.is_truthy(content:find('annotations/', 1, true)) + assert.is_truthy(content:find('openapi/', 1, true)) + assert.is_truthy(content:find('src/', 1, true)) + -- Generated roots are still added + assert.is_truthy(content:find('path="target/generated-sources/src/main/java"', 1, true)) + end) + + it('handles multi-module projects with multiple .classpath files', function() + local module_b_root = path.join(temp_dir, 'module-b') + local module_b_classpath = path.join(module_b_root, '.classpath') + + vim.fn.mkdir(path.join(module_b_root, 'target', 'generated-sources', 'grpc', 'src', 'main', 'java'), 'p') + + vim.fn.writefile({ + '', + '', + '\t', + '\t\t', + '\t\t\t', + '\t\t\t', + '\t\t', + '\t', + '\t', + '\t\t', + '\t\t\t', + '\t\t\t', + '\t\t', + '\t', + '\t', + '', + }, module_b_classpath) + + assert.is_true(classpath.patch(temp_dir)) + + -- Module A (original) is patched + local content_a = read_classpath() + assert.is_truthy(content_a:find('path="target/generated-sources/src/main/java"', 1, true)) + + -- Module B is also patched + local content_b = table.concat(vim.fn.readfile(module_b_classpath), '\n') + assert.is_truthy(content_b:find('path="target/generated-sources/grpc/src/main/java"', 1, true)) + end) end)