Skip to content
Open
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
31 changes: 31 additions & 0 deletions plugin-core/src/main/java/appland/files/AppMapFileLookup.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package appland.files;

import com.intellij.openapi.extensions.ExtensionPointName;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.vfs.VirtualFile;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

/**
* Extension point to locate files referenced in AppMaps, e.g. from external libraries.
*/
public interface AppMapFileLookup {
ExtensionPointName<AppMapFileLookup> EP_NAME = ExtensionPointName.create("appland.files.fileLookup");

/**
* Context data for file lookup.
*
* @param relativePath Path from AppMap, typically relative to appmap.yml

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's unclear from the documentation if relativePath is a /-delimited path or not.
I think it should be added here that it's a /-delimited path (because it's passed down from FileLookup)

* @param line 1-based line number from AppMap, or null if not available
*/
record Data(@NotNull String relativePath, @Nullable Integer line) {
}

/**
* @param project Current project
* @param data Context data for the lookup
* @return The target file, if found by this contributor.
*/
@Nullable
VirtualFile findFile(@NotNull Project project, @NotNull Data data);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I'm not mistaken, data.line is currently always null and data is only used by FileLookup.
Why not inline data into parameters relativePath and line?

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package appland.files;

import com.intellij.openapi.diagnostic.Logger;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.vfs.VirtualFile;
import com.intellij.psi.search.FilenameIndex;
import com.intellij.psi.search.GlobalSearchScope;
import com.intellij.util.PathUtil;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

import java.util.Collection;
import java.util.List;

/**
* Generic fallback implementation for finding files in external libraries.
* <p>
* This lookup searches across all project scopes (including libraries) using filename
* and relative path matching. It runs after language-specific lookups (order="last")
* and supports any file type (Python, Ruby, JavaScript, etc.).
* <p>
* Uses {@link FilenameIndex} for performance and filters by relative path suffix to
* minimize false matches.
*/
public class AppMapGenericFileLookup implements AppMapFileLookup {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The logic implemented here is basically the same as the for loop of appland.files.FileLookup#findRelativeFile(com.intellij.openapi.project.Project, com.intellij.psi.search.GlobalSearchScope, com.intellij.openapi.vfs.VirtualFile, java.lang.String), but only with a broader scope.
Perhaps extract that into a method and either call it with the broader scope directly in FileLookup or from this fallback implementation?

private static final Logger LOG = Logger.getInstance(AppMapGenericFileLookup.class);

@Override
public @Nullable VirtualFile findFile(@NotNull Project project, @NotNull Data data) {
// Search by filename across all scopes (project + libraries)
String filename = PathUtil.getFileName(data.relativePath()); // e.g., "Filter.java"
Collection<VirtualFile> candidates = FilenameIndex.getVirtualFilesByName(
filename, true, GlobalSearchScope.allScope(project)
);

// Filter to only files whose full path ends with the relative path
// This is specific enough to avoid most false matches
List<VirtualFile> matches = candidates.stream()
.filter(file -> file.getPath().endsWith(data.relativePath()))
.toList();
Comment on lines +36 to +40

Copilot AI Feb 13, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The suffix filter uses file.getPath().endsWith(data.relativePath()), which is case-sensitive and doesn’t apply the same case-sensitivity rules as the existing FileLookup logic (which uses FileUtil.namesEqual). On Windows / case-insensitive file systems, AppMap paths that differ only by case may fail to resolve. Normalize both paths and apply a case-insensitive comparison when appropriate (e.g., via IntelliJ FileUtil helpers).

Copilot uses AI. Check for mistakes.

if (matches.isEmpty()) return null;

if (matches.size() > 1) {
LOG.warn("Multiple candidates found for " + data.relativePath() +
", using first match from: " + matches);
}

return matches.get(0);
Comment on lines +44 to +49

Copilot AI Feb 13, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When multiple candidates match, the implementation logs a warning and returns matches.get(0) without any deterministic ordering. FilenameIndex result ordering is not guaranteed, so navigation may be flaky between IDE runs. Consider applying a stable tie-breaker (e.g., prefer non-archive sources vs decompiled/class entries, then shortest path / exact path match) and avoid logging the full list of matches (which can be very large for common filenames).

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FileLookup prefers shorter paths by sorting by length in ascending order.

}
}
15 changes: 15 additions & 0 deletions plugin-core/src/main/java/appland/files/FileLookup.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import appland.index.AppMapSearchScopes;
import com.google.common.collect.Lists;
import com.intellij.openapi.diagnostic.Logger;
import com.intellij.openapi.project.DumbService;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.util.SystemInfo;
Expand Down Expand Up @@ -29,6 +30,8 @@
* This class tries to locate the best matching file in the project.
*/
public class FileLookup {
private static final Logger LOG = Logger.getInstance(FileLookup.class);

/**
* @param project Current project
* @param base The base file or directory. This usually is the currently opened appmap file. {@code null} indicates that no context is available.
Expand Down Expand Up @@ -98,6 +101,18 @@ public class FileLookup {
}
}

// Fallback: try extension points for finding files in external libraries
// Language-specific extensions (e.g., Java) run first, followed by generic lookup
LOG.debug("File not found in project content, trying extension lookups: " + relativePath);
var data = new AppMapFileLookup.Data(relativePath, null);
for (var lookup : AppMapFileLookup.EP_NAME.getExtensionList()) {
var file = lookup.findFile(project, data);
if (file != null) {
LOG.debug("File found by extension " + lookup + ": " + file.getPath());
return file;
Comment on lines +106 to +112

Copilot AI Feb 13, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

findRelativeFile(project, searchScope, ...) now falls back to AppMapFileLookup extensions even when a restrictive searchScope is provided. This can return a file outside the requested scope (e.g., NavieEditor passes a directory scope), which changes behavior from “not found” to “open some library match”. Consider skipping extension lookup when searchScope is non-null, or extending the EP API so contributors can honor the provided scope.

Suggested change
LOG.debug("File not found in project content, trying extension lookups: " + relativePath);
var data = new AppMapFileLookup.Data(relativePath, null);
for (var lookup : AppMapFileLookup.EP_NAME.getExtensionList()) {
var file = lookup.findFile(project, data);
if (file != null) {
LOG.debug("File found by extension " + lookup + ": " + file.getPath());
return file;
if (searchScope == null) {
LOG.debug("File not found in project content, trying extension lookups: " + relativePath);
var data = new AppMapFileLookup.Data(relativePath, null);
for (var lookup : AppMapFileLookup.EP_NAME.getExtensionList()) {
var file = lookup.findFile(project, data);
if (file != null) {
LOG.debug("File found by extension " + lookup + ": " + file.getPath());
return file;
}

Copilot uses AI. Check for mistakes.
}
}

// no match :(
return null;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -181,9 +181,19 @@ private static void showSource(@NotNull Project project,
return;
}

LOG.debug("Resolved file for " + relativePath + ": " + referencedFile.getPath());

ApplicationManager.getApplication().invokeLater(() -> {
int line = location.getZeroBasedLine(-1);
// Ignore line numbers for .class files - decompiled text layout rarely matches
// the original source line numbers, so jumping to a specific line usually lands
// in the wrong place. Open at the top instead.
if (referencedFile.getName().endsWith(".class")) {
line = -1;
}

// IntelliJ's lines are 0-based, AppMap lines seem to be 1-based
var descriptor = new OpenFileDescriptor(project, referencedFile, location.getZeroBasedLine(-1), -1);
var descriptor = new OpenFileDescriptor(project, referencedFile, line, -1);
OpenInRightSplit.openInRightSplit(project, referencedFile, descriptor);
}, ModalityState.defaultModalityState());
}
Expand Down
7 changes: 7 additions & 0 deletions plugin-core/src/main/resources/META-INF/appmap-core.xml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@
</projectListeners>

<extensionPoints>
<extensionPoint qualifiedName="appland.files.fileLookup" interface="appland.files.AppMapFileLookup"
dynamic="true"/>

<extensionPoint dynamic="true"
qualifiedName="appland.cli.envProvider"
interface="appland.cli.AppLandCliEnvProvider"/>
Expand All @@ -55,6 +58,10 @@
implementation="appland.webviews.navie.NavieLanguageModelEnvProvider"/>
</extensions>

<extensions defaultExtensionNs="appland.files">
<fileLookup implementation="appland.files.AppMapGenericFileLookup" order="last"/>
</extensions>

<extensions defaultExtensionNs="com.intellij">
<applicationService serviceImplementation="appland.telemetry.TelemetryService"/>
<applicationService serviceImplementation="appland.AppLandLifecycleService"/>
Expand Down
34 changes: 34 additions & 0 deletions plugin-core/src/test/java/appland/cli/CliToolsTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.PosixFilePermission;
import java.util.List;

public class CliToolsTest extends AppMapBaseTest {
Expand Down Expand Up @@ -94,6 +95,39 @@ public void bundledBinaryIsPreferred() throws Exception {
});
}

@Test
public void testBundledBinaryPermissionsFixed() throws Exception {
if (!SystemInfo.isUnix) {
return;
}
Comment on lines +100 to +102

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: WIth an assumption it would show as an ignored test on Windows.

Suggested change
if (!SystemInfo.isUnix) {
return;
}
Assume.assumeTrue(SystemInfo.isUnix);


// Mock binary name that matches the current platform/arch
var binaryName = String.format("appmap-%s-%s-v1000.0.0", CliTools.currentPlatform(), CliTools.currentArch());
var mockBinary = createMockBinary(binaryName);

AppMapDeploymentTestUtils.withBundledBinaries(List.of(mockBinary), () -> {
// Find the file that was just copied
var bundledPath = AppMapDeploymentSettingsService.bundledBinarySearchPath().get(0).resolve(binaryName);

// Force it to be non-executable first
if (Files.isExecutable(bundledPath)) {
var perms = Files.getPosixFilePermissions(bundledPath);
perms.remove(PosixFilePermission.OWNER_EXECUTE);
perms.remove(PosixFilePermission.GROUP_EXECUTE);
perms.remove(PosixFilePermission.OTHERS_EXECUTE);
Files.setPosixFilePermissions(bundledPath, perms);
}
assertFalse(Files.isExecutable(bundledPath));

// Now call the method under test
var binaryPath = CliTools.getBinaryPath(CliTool.AppMap);

assertNotNull(binaryPath);
assertEquals(binaryName, binaryPath.getFileName().toString());
assertTrue("Bundled binary must be made executable", Files.isExecutable(binaryPath));
});
}

private @NotNull Path createMockBinary(String filename) {
return myFixture.getTempDirFixture().createFile(filename).toNioPath();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
package appland.files;

import com.intellij.openapi.Disposable;
import com.intellij.openapi.util.Disposer;
import com.intellij.psi.PsiFile;
import com.intellij.psi.search.GlobalSearchScope;
import com.intellij.psi.search.GlobalSearchScopes;
import com.intellij.testFramework.fixtures.LightPlatformCodeInsightFixture4TestCase;
import org.junit.Test;
Expand All @@ -10,6 +11,41 @@
import java.util.Collections;

public class AppMapFileLookupTest extends LightPlatformCodeInsightFixture4TestCase {
@Test
public void extensionLookup() {
var base = myFixture.configureByText("a.txt", "").getVirtualFile();
// file not in project
var notFound = FileLookup.findRelativeFile(getProject(), base, "external/file.txt");
assertNull(notFound);

// Register extension
Disposable disposable = Disposer.newDisposable();
try {
AppMapFileLookup.EP_NAME.getPoint().registerExtension((project, data) -> {
if ("external/file.txt".equals(data.relativePath())) {
return base; // return base file as mock result
}
return null;
}, disposable);

var found = FileLookup.findRelativeFile(getProject(), base, "external/file.txt");
assertEquals(base, found);
} finally {
Disposer.dispose(disposable);
}
}

@Test
public void genericLookup() {
var target = myFixture.addFileToProject("libs/dep/src/org/example/Lib.java", "").getVirtualFile();

var lookup = new AppMapGenericFileLookup();
var data = new AppMapFileLookup.Data("org/example/Lib.java", null);

var found = lookup.findFile(getProject(), data);
assertEquals(target, found);
}

@Test
public void locateFilenameByRelativePath() {
PsiFile base = myFixture.configureByText("a.txt", "");
Expand Down
44 changes: 44 additions & 0 deletions plugin-java/src/main/java/appland/java/AppMapJavaFileLookup.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package appland.java;

import appland.files.AppMapFileLookup;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.vfs.VirtualFile;
import com.intellij.psi.JavaPsiFacade;
import com.intellij.psi.PsiClass;
import com.intellij.psi.search.GlobalSearchScope;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

/**
* Java-specific implementation for finding classes in external libraries (JARs).
* <p>
* Converts file paths to fully qualified class names and uses {@link JavaPsiFacade}
* to locate classes. This automatically:
* <ul>
* <li>Resolves to source attachments when available</li>
* <li>Falls back to decompiled view if no source is attached</li>
* <li>Handles inner classes and classpath resolution correctly</li>
* </ul>
* This is the same mechanism IntelliJ's "Go to Class" uses internally.
*/
public class AppMapJavaFileLookup implements AppMapFileLookup {
@Override
public @Nullable VirtualFile findFile(@NotNull Project project, @NotNull Data data) {
String relativePath = data.relativePath();
if (!relativePath.endsWith(".java")) {
return null;
}

// Convert path to FQN: org/springframework/web/filter/Filter.java -> org.springframework.web.filter.Filter
String fqn = relativePath.substring(0, relativePath.length() - ".java".length())
.replace('/', '.')
.replace('\\', '.');

PsiClass psiClass = JavaPsiFacade.getInstance(project).findClass(fqn, GlobalSearchScope.allScope(project));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs a read access and also smart mode because of the index access.
It's unclear if this method is executed in with read access (ReadAction or dispatch thread), so this should hopefully fix both issues:

Suggested change
PsiClass psiClass = JavaPsiFacade.getInstance(project).findClass(fqn, GlobalSearchScope.allScope(project));
var psiClass = ReadAction.nonBlocking(() -> {
return JavaPsiFacade.getInstance(project).findClass(fqn, GlobalSearchScope.allScope(project));
}
).inSmartMode(project).executeSynchronously();

if (psiClass == null) {
return null;
}

return psiClass.getContainingFile().getVirtualFile();
}
}
4 changes: 4 additions & 0 deletions plugin-java/src/main/resources/META-INF/appmap-java.xml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@
<programPatcher implementation="appland.execution.AppMapJavaProgramPatcher"/>
</extensions>

<extensions defaultExtensionNs="appland.files">
<fileLookup implementation="appland.java.AppMapJavaFileLookup"/>
</extensions>

<projectListeners>
<listener topic="com.intellij.execution.ExecutionListener"
class="appland.execution.AppMapExecutionListener"/>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package appland.java;

import appland.files.AppMapFileLookup;
import com.intellij.testFramework.fixtures.LightJavaCodeInsightFixtureTestCase;

public class AppMapJavaFileLookupTest extends LightJavaCodeInsightFixtureTestCase {
public void testJavaLookup() {
myFixture.addClass("package org.example; public class Lib {}");

var lookup = new AppMapJavaFileLookup();
var data = new AppMapFileLookup.Data("org/example/Lib.java", null);

var found = lookup.findFile(getProject(), data);
assertNotNull(found);
assertEquals("Lib.java", found.getName());
}
}
Loading