diff --git a/src/main/java/com/indexer/mcp/QueryExecutor.java b/src/main/java/com/indexer/mcp/QueryExecutor.java index 1ed6eb5..35fb48c 100644 --- a/src/main/java/com/indexer/mcp/QueryExecutor.java +++ b/src/main/java/com/indexer/mcp/QueryExecutor.java @@ -15,13 +15,10 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.io.IOException; -import java.nio.file.Files; import java.nio.file.Path; import java.time.Instant; import java.util.*; import java.util.function.Supplier; -import java.util.stream.Collectors; /** * Core query engine for the MCP server. All MCP tools delegate to this class. @@ -241,7 +238,8 @@ public Map getSymbolDetail(String repo, String filePath, String sb.append(""" SELECT s.id, s.name, s.kind, s.signature, s.start_line, s.end_line, s.parent_id, s.visibility, s.is_static, - ef.path AS file_path, r.name AS repo_name, r.clone_path + ef.id AS file_id, + ef.path AS file_path, r.name AS repo_name FROM symbols s JOIN effective_files ef ON s.file_id = ef.id JOIN repositories r ON ef.repo_id = r.id @@ -277,10 +275,18 @@ public Map getSymbolDetail(String repo, String filePath, String int symbolId = ((Number) symbol.get("id")).intValue(); int startLine = ((Number) symbol.get("start_line")).intValue(); int endLine = ((Number) symbol.get("end_line")).intValue(); - String clonePath = (String) symbol.get("clone_path"); + long fileId = ((Number) symbol.get("file_id")).longValue(); - // Read source lines from disk - symbol.put("source_code", readSourceLines(clonePath, filePath, startLine, endLine)); + // Read source from the overlay-resolved file_contents row (ref-aware), + // not the on-disk working tree (which is always checked out to main). + String content = handle.createQuery( + "SELECT content FROM file_contents WHERE file_id = :fileId") + .bind("fileId", fileId) + .mapTo(String.class) + .findOne() + .orElse(null); + symbol.put("source_code", sliceLines(content, startLine, endLine)); + symbol.remove("file_id"); // internal id — not part of the response contract // Fetch children (e.g., methods if this is a class) var children = handle.createQuery(""" @@ -1649,22 +1655,24 @@ private void ensureBranchIndexed(String repo, String effectiveBranch) { } } - private String readSourceLines(String clonePath, String filePath, int startLine, int endLine) { - if (clonePath == null || clonePath.isBlank()) { + /** + * Slice lines [startLine, endLine] (1-based, inclusive) out of stored file content. + * Mirrors the old disk-based readSourceLines semantics but reads from file_contents + * (ref-aware via the effective_files overlay) instead of the working tree. + * Returns null when content is null (binary/oversized/metadata-only files). + */ + private static String sliceLines(String content, int startLine, int endLine) { + if (content == null) { return null; } - try { - Path fullPath = Path.of(clonePath).resolve(filePath); - if (!Files.exists(fullPath)) { - return null; - } - List lines = Files.readAllLines(fullPath); - int from = Math.max(0, startLine - 1); - int to = Math.min(lines.size(), endLine); - return lines.subList(from, to).stream().collect(Collectors.joining("\n")); - } catch (IOException e) { - log.warn("Could not read source lines from {}/{}: {}", clonePath, filePath, e.getMessage()); - return null; + // String.lines() splits on \n, \r, and \r\n and drops a trailing terminator, + // matching Files.readAllLines — so symbol line numbers line up. + List lines = content.lines().toList(); + int from = Math.max(0, startLine - 1); + int to = Math.min(lines.size(), endLine); + if (from >= to) { + return ""; } + return String.join("\n", lines.subList(from, to)); } } diff --git a/src/test/java/com/indexer/mcp/tools/BranchQueryTest.java b/src/test/java/com/indexer/mcp/tools/BranchQueryTest.java index 51c5ba2..a59092a 100644 --- a/src/test/java/com/indexer/mcp/tools/BranchQueryTest.java +++ b/src/test/java/com/indexer/mcp/tools/BranchQueryTest.java @@ -31,12 +31,13 @@ class BranchQueryTest { private int repoId; private FileDao fileDao; private SymbolDao symbolDao; + private Jdbi jdbi; @BeforeEach void setUp() { var dbManager = new DatabaseManager(postgres.getJdbcUrl(), postgres.getUsername(), postgres.getPassword()); dbManager.initialize(); - var jdbi = dbManager.getJdbi(); + jdbi = dbManager.getJdbi(); jdbi.useHandle(h -> { h.execute("DELETE FROM type_relationships"); h.execute("DELETE FROM imports"); @@ -196,4 +197,65 @@ void getRepoSummaryCountsEffectiveFilesForBranch() { var branchSummary = queryExecutor.getRepoSummary("test-repo", "feature/new-auth"); assertThat(((Number) branchSummary.get("fileCount")).intValue()).isEqualTo(2); } + + @SuppressWarnings("unchecked") + @Test + void getSymbolDetailReturnsSourceForMainFile() { + int fileId = fileDao.upsert(new SourceFile(0, repoId, "main", "src/Detail.java", "java", 100, "abc", Instant.now())); + symbolDao.insertSymbol(new Symbol(0, fileId, "Detail", "class", "public class Detail", 2, 4, null, "public", false)); + insertContent(fileId, "package x;\npublic class Detail {\n int f;\n}\n"); + + var detail = queryExecutor.getSymbolDetail("test-repo", "src/Detail.java", "Detail", null, "main"); + + assertThat((String) detail.get("source_code")).isEqualTo("public class Detail {\n int f;\n}"); + } + + @SuppressWarnings("unchecked") + @Test + void getSymbolDetailReturnsSourceForBranchOnlyFile() { + // File exists ONLY on the feature branch — the on-disk working tree (main) has no such file, + // so the old disk read returned null. This is the core bug. + int fileId = fileDao.upsert(new SourceFile(0, repoId, "feature/detail", "src/Only.java", "java", 100, "def", Instant.now())); + symbolDao.insertSymbol(new Symbol(0, fileId, "doThing", "method", "void doThing()", 1, 2, null, "public", false)); + insertContent(fileId, "void doThing() {\n return;\n}\n"); + + var detail = queryExecutor.getSymbolDetail("test-repo", "src/Only.java", "doThing", null, "feature/detail"); + + assertThat((String) detail.get("source_code")).isEqualTo("void doThing() {\n return;"); + } + + @SuppressWarnings("unchecked") + @Test + void getSymbolDetailReturnsNullSourceForMetadataOnlyFile() { + // A file row + symbol but NO file_contents row (binary/oversized → content never stored). + int fileId = fileDao.upsert(new SourceFile(0, repoId, "main", "bin/Blob.bin", "binary", 999999, "abc", Instant.now())); + symbolDao.insertSymbol(new Symbol(0, fileId, "Blob", "class", "n/a", 1, 1, null, "public", false)); + // intentionally no insertContent(...) + + var detail = queryExecutor.getSymbolDetail("test-repo", "bin/Blob.bin", "Blob", null, "main"); + + assertThat(detail.get("source_code")).isNull(); + } + + @SuppressWarnings("unchecked") + @Test + void getSymbolDetailSlicesCorrectLineRange() { + int fileId = fileDao.upsert(new SourceFile(0, repoId, "main", "src/Slice.java", "java", 100, "abc", Instant.now())); + symbolDao.insertSymbol(new Symbol(0, fileId, "mid", "method", "void mid()", 3, 5, null, "public", false)); + insertContent(fileId, "L1\nL2\nL3\nL4\nL5\nL6\n"); + + var detail = queryExecutor.getSymbolDetail("test-repo", "src/Slice.java", "mid", null, "main"); + + assertThat((String) detail.get("source_code")).isEqualTo("L3\nL4\nL5"); + } + + /** Insert (or replace) the stored content for a file. The DB trigger fills search_vector. */ + private void insertContent(int fileId, String content) { + jdbi.useHandle(h -> h.createUpdate( + "INSERT INTO file_contents (file_id, content) VALUES (:fid, :c) " + + "ON CONFLICT (file_id) DO UPDATE SET content = EXCLUDED.content") + .bind("fid", fileId) + .bind("c", content) + .execute()); + } }