Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 29 additions & 21 deletions src/main/java/com/indexer/mcp/QueryExecutor.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -241,7 +238,8 @@ public Map<String, Object> 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
Expand Down Expand Up @@ -277,10 +275,18 @@ public Map<String, Object> 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("""
Expand Down Expand Up @@ -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<String> 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<String> 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));
}
}
64 changes: 63 additions & 1 deletion src/test/java/com/indexer/mcp/tools/BranchQueryTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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());
}
}
Loading