diff --git a/plugin-core/src/main/java/appland/files/AppMapFileLookup.java b/plugin-core/src/main/java/appland/files/AppMapFileLookup.java new file mode 100644 index 000000000..fcb3bc7e5 --- /dev/null +++ b/plugin-core/src/main/java/appland/files/AppMapFileLookup.java @@ -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 EP_NAME = ExtensionPointName.create("appland.files.fileLookup"); + + /** + * Context data for file lookup. + * + * @param relativePath Path from AppMap, typically relative to appmap.yml + * @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); +} diff --git a/plugin-core/src/main/java/appland/files/AppMapGenericFileLookup.java b/plugin-core/src/main/java/appland/files/AppMapGenericFileLookup.java new file mode 100644 index 000000000..734f84a38 --- /dev/null +++ b/plugin-core/src/main/java/appland/files/AppMapGenericFileLookup.java @@ -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. + *

+ * 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.). + *

+ * Uses {@link FilenameIndex} for performance and filters by relative path suffix to + * minimize false matches. + */ +public class AppMapGenericFileLookup implements AppMapFileLookup { + 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 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 matches = candidates.stream() + .filter(file -> file.getPath().endsWith(data.relativePath())) + .toList(); + + 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); + } +} diff --git a/plugin-core/src/main/java/appland/files/FileLookup.java b/plugin-core/src/main/java/appland/files/FileLookup.java index 9f6ce06c4..3fcfd446f 100644 --- a/plugin-core/src/main/java/appland/files/FileLookup.java +++ b/plugin-core/src/main/java/appland/files/FileLookup.java @@ -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; @@ -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. @@ -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; + } + } + // no match :( return null; } diff --git a/plugin-core/src/main/java/appland/webviews/SharedAppMapWebViewMessages.java b/plugin-core/src/main/java/appland/webviews/SharedAppMapWebViewMessages.java index 38f0fcdc3..6f040ed86 100644 --- a/plugin-core/src/main/java/appland/webviews/SharedAppMapWebViewMessages.java +++ b/plugin-core/src/main/java/appland/webviews/SharedAppMapWebViewMessages.java @@ -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()); } diff --git a/plugin-core/src/main/resources/META-INF/appmap-core.xml b/plugin-core/src/main/resources/META-INF/appmap-core.xml index 569a55cae..52e81787e 100644 --- a/plugin-core/src/main/resources/META-INF/appmap-core.xml +++ b/plugin-core/src/main/resources/META-INF/appmap-core.xml @@ -39,6 +39,9 @@ + + @@ -55,6 +58,10 @@ implementation="appland.webviews.navie.NavieLanguageModelEnvProvider"/> + + + + diff --git a/plugin-core/src/test/java/appland/cli/CliToolsTest.java b/plugin-core/src/test/java/appland/cli/CliToolsTest.java index bb391ac53..bc3ccbfe1 100644 --- a/plugin-core/src/test/java/appland/cli/CliToolsTest.java +++ b/plugin-core/src/test/java/appland/cli/CliToolsTest.java @@ -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 { @@ -94,6 +95,39 @@ public void bundledBinaryIsPreferred() throws Exception { }); } + @Test + public void testBundledBinaryPermissionsFixed() throws Exception { + if (!SystemInfo.isUnix) { + return; + } + + // 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(); } diff --git a/plugin-core/src/test/java/appland/files/AppMapFileLookupTest.java b/plugin-core/src/test/java/appland/files/AppMapFileLookupTest.java index 230a64869..7153622ea 100644 --- a/plugin-core/src/test/java/appland/files/AppMapFileLookupTest.java +++ b/plugin-core/src/test/java/appland/files/AppMapFileLookupTest.java @@ -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; @@ -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", ""); diff --git a/plugin-java/src/main/java/appland/java/AppMapJavaFileLookup.java b/plugin-java/src/main/java/appland/java/AppMapJavaFileLookup.java new file mode 100644 index 000000000..6afabec69 --- /dev/null +++ b/plugin-java/src/main/java/appland/java/AppMapJavaFileLookup.java @@ -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). + *

+ * Converts file paths to fully qualified class names and uses {@link JavaPsiFacade} + * to locate classes. This automatically: + *

+ * 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)); + if (psiClass == null) { + return null; + } + + return psiClass.getContainingFile().getVirtualFile(); + } +} diff --git a/plugin-java/src/main/resources/META-INF/appmap-java.xml b/plugin-java/src/main/resources/META-INF/appmap-java.xml index 47651660f..98fd40f3f 100644 --- a/plugin-java/src/main/resources/META-INF/appmap-java.xml +++ b/plugin-java/src/main/resources/META-INF/appmap-java.xml @@ -9,6 +9,10 @@ + + + + diff --git a/plugin-java/src/test/java/appland/java/AppMapJavaFileLookupTest.java b/plugin-java/src/test/java/appland/java/AppMapJavaFileLookupTest.java new file mode 100644 index 000000000..11f1fffe6 --- /dev/null +++ b/plugin-java/src/test/java/appland/java/AppMapJavaFileLookupTest.java @@ -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()); + } +}