From d0f3b9eccc17ba51325017a917baaccbffb2de31 Mon Sep 17 00:00:00 2001 From: Ethan Hou Date: Thu, 23 Apr 2026 16:17:37 +0800 Subject: [PATCH 1/6] feat: implement file and text search functionality with glob pattern support --- .../core/lsp/CopilotLanguageClient.java | 20 ++ .../core/lsp/protocol/FindFilesParams.java | 84 +++++ .../core/lsp/protocol/FindFilesResult.java | 59 ++++ .../lsp/protocol/FindTextInFilesParams.java | 110 ++++++ .../lsp/protocol/FindTextInFilesResult.java | 69 ++++ .../copilot/eclipse/core/utils/FileUtils.java | 319 ++++++++++++++++-- 6 files changed, 630 insertions(+), 31 deletions(-) create mode 100644 com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/FindFilesParams.java create mode 100644 com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/FindFilesResult.java create mode 100644 com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/FindTextInFilesParams.java create mode 100644 com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/FindTextInFilesResult.java diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageClient.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageClient.java index 4320c072..f2bde857 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageClient.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageClient.java @@ -47,6 +47,10 @@ import com.microsoft.copilot.eclipse.core.lsp.protocol.ConversationContextParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.CurrentEditorContext; import com.microsoft.copilot.eclipse.core.lsp.protocol.DidChangeFeatureFlagsParams; +import com.microsoft.copilot.eclipse.core.lsp.protocol.FindFilesParams; +import com.microsoft.copilot.eclipse.core.lsp.protocol.FindFilesResult; +import com.microsoft.copilot.eclipse.core.lsp.protocol.FindTextInFilesParams; +import com.microsoft.copilot.eclipse.core.lsp.protocol.FindTextInFilesResult; import com.microsoft.copilot.eclipse.core.lsp.protocol.GetWatchedFilesRequest; import com.microsoft.copilot.eclipse.core.lsp.protocol.GetWatchedFilesResponse; import com.microsoft.copilot.eclipse.core.lsp.protocol.InvokeClientToolConfirmationParams; @@ -345,6 +349,22 @@ public CompletableFuture readDirectory(String uri) { return CompletableFuture.supplyAsync(() -> FileUtils.readDirectoryEntries(uri)); } + /** + * Searches for files matching a glob pattern under the given base URI. + */ + @JsonRequest("workspace/findFiles") + public CompletableFuture findFiles(FindFilesParams params) { + return CompletableFuture.supplyAsync(() -> FileUtils.findFiles(params)); + } + + /** + * Searches for text (or a regex) in files under the given base URI. + */ + @JsonRequest("workspace/findTextInFiles") + public CompletableFuture findTextInFiles(FindTextInFilesParams params) { + return CompletableFuture.supplyAsync(() -> FileUtils.findTextInFiles(params)); + } + /** * Handles the progress notification for chat replies. */ diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/FindFilesParams.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/FindFilesParams.java new file mode 100644 index 00000000..b8e73136 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/FindFilesParams.java @@ -0,0 +1,84 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.lsp.protocol; + +import java.util.Objects; + +import org.apache.commons.lang3.builder.ToStringBuilder; + +/** + * Parameters for the {@code workspace/findFiles} request. Used by the language server to ask the client to search for + * files matching a glob pattern under a given base URI (e.g. a semanticfs workspace folder). + */ +public class FindFilesParams { + + private String baseUri; + private String pattern; + private Integer maxResults; + + /** + * Constructs a new FindFilesParams object. + * + * @param baseUri the base URI to search under (e.g. a semanticfs workspace folder) + * @param pattern the glob pattern to match file paths against + * @param maxResults the maximum number of results to return (optional) + */ + public FindFilesParams(String baseUri, String pattern, Integer maxResults) { + this.baseUri = baseUri; + this.pattern = pattern; + this.maxResults = maxResults; + } + + public String getBaseUri() { + return baseUri; + } + + public void setBaseUri(String baseUri) { + this.baseUri = baseUri; + } + + public String getPattern() { + return pattern; + } + + public void setPattern(String pattern) { + this.pattern = pattern; + } + + public Integer getMaxResults() { + return maxResults; + } + + public void setMaxResults(Integer maxResults) { + this.maxResults = maxResults; + } + + @Override + public int hashCode() { + return Objects.hash(baseUri, pattern, maxResults); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + FindFilesParams other = (FindFilesParams) obj; + return Objects.equals(baseUri, other.baseUri) && Objects.equals(pattern, other.pattern) + && Objects.equals(maxResults, other.maxResults); + } + + @Override + public String toString() { + ToStringBuilder builder = new ToStringBuilder(this); + builder.append("baseUri", baseUri); + builder.append("pattern", pattern); + builder.append("maxResults", maxResults); + return builder.toString(); + } + +} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/FindFilesResult.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/FindFilesResult.java new file mode 100644 index 00000000..a504e161 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/FindFilesResult.java @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.lsp.protocol; + +import java.util.List; +import java.util.Objects; + +import org.apache.commons.lang3.builder.ToStringBuilder; + +/** + * Result of the {@code workspace/findFiles} request, containing URIs of files matching the glob pattern. + */ +public class FindFilesResult { + + private List uris; + + /** + * Constructs a new FindFilesResult object. + * + * @param uris the list of file URIs matching the glob pattern + */ + public FindFilesResult(List uris) { + this.uris = uris; + } + + public List getUris() { + return uris; + } + + public void setUris(List uris) { + this.uris = uris; + } + + @Override + public int hashCode() { + return Objects.hash(uris); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + FindFilesResult other = (FindFilesResult) obj; + return Objects.equals(uris, other.uris); + } + + @Override + public String toString() { + ToStringBuilder builder = new ToStringBuilder(this); + builder.append("uris", uris); + return builder.toString(); + } + +} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/FindTextInFilesParams.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/FindTextInFilesParams.java new file mode 100644 index 00000000..f88cbdad --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/FindTextInFilesParams.java @@ -0,0 +1,110 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.lsp.protocol; + +import java.util.Objects; + +import org.apache.commons.lang3.builder.ToStringBuilder; + +/** + * Parameters for the {@code workspace/findTextInFiles} request. Used by the language server to ask the client to search + * for text (or a regex) in files under a given base URI. + */ +public class FindTextInFilesParams { + + private String baseUri; + private String query; + private Boolean isRegexp; + private String includePattern; + private Integer maxResults; + + /** + * Constructs a new FindTextInFilesParams object. + * + * @param baseUri the base URI to search under (e.g. a semanticfs workspace folder) + * @param query the text or regex pattern to search for in files + * @param isRegexp whether the query is a regular expression + * @param includePattern an optional glob pattern to filter which files to search + * @param maxResults the maximum number of results to return (optional) + */ + public FindTextInFilesParams(String baseUri, String query, Boolean isRegexp, String includePattern, + Integer maxResults) { + this.baseUri = baseUri; + this.query = query; + this.isRegexp = isRegexp; + this.includePattern = includePattern; + this.maxResults = maxResults; + } + + public String getBaseUri() { + return baseUri; + } + + public void setBaseUri(String baseUri) { + this.baseUri = baseUri; + } + + public String getQuery() { + return query; + } + + public void setQuery(String query) { + this.query = query; + } + + public Boolean getIsRegexp() { + return isRegexp; + } + + public void setIsRegexp(Boolean isRegexp) { + this.isRegexp = isRegexp; + } + + public String getIncludePattern() { + return includePattern; + } + + public void setIncludePattern(String includePattern) { + this.includePattern = includePattern; + } + + public Integer getMaxResults() { + return maxResults; + } + + public void setMaxResults(Integer maxResults) { + this.maxResults = maxResults; + } + + @Override + public int hashCode() { + return Objects.hash(baseUri, query, isRegexp, includePattern, maxResults); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + FindTextInFilesParams other = (FindTextInFilesParams) obj; + return Objects.equals(baseUri, other.baseUri) && Objects.equals(query, other.query) + && Objects.equals(isRegexp, other.isRegexp) && Objects.equals(includePattern, other.includePattern) + && Objects.equals(maxResults, other.maxResults); + } + + @Override + public String toString() { + ToStringBuilder builder = new ToStringBuilder(this); + builder.append("baseUri", baseUri); + builder.append("query", query); + builder.append("isRegexp", isRegexp); + builder.append("includePattern", includePattern); + builder.append("maxResults", maxResults); + return builder.toString(); + } + +} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/FindTextInFilesResult.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/FindTextInFilesResult.java new file mode 100644 index 00000000..b4819b02 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/FindTextInFilesResult.java @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.lsp.protocol; + +import java.util.List; +import java.util.Objects; + +import org.apache.commons.lang3.builder.ToStringBuilder; + +/** + * Result of the {@code workspace/findTextInFiles} request, containing the list of matches. + */ +public class FindTextInFilesResult { + + private List matches; + + /** + * Constructs a new FindTextInFilesResult object. + * + * @param matches the list of text search matches + */ + public FindTextInFilesResult(List matches) { + this.matches = matches; + } + + public List getMatches() { + return matches; + } + + public void setMatches(List matches) { + this.matches = matches; + } + + @Override + public int hashCode() { + return Objects.hash(matches); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + FindTextInFilesResult other = (FindTextInFilesResult) obj; + return Objects.equals(matches, other.matches); + } + + @Override + public String toString() { + ToStringBuilder builder = new ToStringBuilder(this); + builder.append("matches", matches); + return builder.toString(); + } + + /** + * A single text search match. Field names mirror the CLS protocol. + * + * @param uri the URI of the file containing the match + * @param lineNumber the 1-based line number of the match within the file + * @param lineText the full text of the line containing the match + */ + public record TextSearchMatch(String uri, int lineNumber, String lineText) { + } + +} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/utils/FileUtils.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/utils/FileUtils.java index fd4814d9..99a3ec95 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/utils/FileUtils.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/utils/FileUtils.java @@ -3,20 +3,26 @@ package com.microsoft.copilot.eclipse.core.utils; +import java.io.BufferedReader; import java.io.IOException; import java.io.InputStream; +import java.io.InputStreamReader; import java.net.URI; import java.net.URISyntaxException; +import java.nio.file.FileSystems; import java.nio.file.Files; import java.nio.file.LinkOption; import java.nio.file.Path; +import java.nio.file.PathMatcher; import java.nio.file.Paths; import java.nio.file.attribute.BasicFileAttributes; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Objects; +import java.util.regex.Matcher; import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; import java.util.stream.Collectors; import org.apache.commons.lang3.StringUtils; @@ -37,6 +43,11 @@ import com.microsoft.copilot.eclipse.core.lsp.protocol.DirectoryChatReference; import com.microsoft.copilot.eclipse.core.lsp.protocol.FileChatReference; import com.microsoft.copilot.eclipse.core.lsp.protocol.FileStat; +import com.microsoft.copilot.eclipse.core.lsp.protocol.FindFilesParams; +import com.microsoft.copilot.eclipse.core.lsp.protocol.FindFilesResult; +import com.microsoft.copilot.eclipse.core.lsp.protocol.FindTextInFilesParams; +import com.microsoft.copilot.eclipse.core.lsp.protocol.FindTextInFilesResult; +import com.microsoft.copilot.eclipse.core.lsp.protocol.FindTextInFilesResult.TextSearchMatch; import com.microsoft.copilot.eclipse.core.lsp.protocol.ReadDirectoryResult; import com.microsoft.copilot.eclipse.core.lsp.protocol.ReadDirectoryResult.DirectoryEntry; import com.microsoft.copilot.eclipse.core.lsp.protocol.ReadFileResult; @@ -253,6 +264,15 @@ public static IFile getFileFromPath(String filePath, boolean checkExistence) { return null; } + // Try URI-based resolution first for non-filesystem URI schemes (e.g., semanticfs://) + if (URI_SCHEME_PATTERN.matcher(filePath).find() && !filePath.startsWith("file:")) { + IResource resource = getResourceFromUri(filePath); + if (resource instanceof IFile file) { + return file; + } + return null; + } + IWorkspaceRoot root = ResourcesPlugin.getWorkspace().getRoot(); IPath eclipsePath = org.eclipse.core.runtime.Path.fromOSString(filePath); @@ -322,34 +342,7 @@ public static ReadDirectoryResult readDirectoryEntries(String uri) { } try { - URI parsedUri = new URI(uri); - IWorkspaceRoot root = ResourcesPlugin.getWorkspace().getRoot(); - - IContainer container = null; - if ("platform".equals(parsedUri.getScheme())) { - // Handle platform:/resource/... URIs by resolving via workspace path - String path = parsedUri.getPath(); - String prefix = "/resource"; - if (path != null && path.startsWith(prefix)) { - String workspacePath = path.substring(prefix.length()); - IResource resource = root.findMember(workspacePath); - if (resource instanceof IContainer c && c.isAccessible()) { - container = c; - } - } - } else { - // For file://, semanticfs://, and other URIs, use location URI lookup - IContainer[] containers = root.findContainersForLocationURI(parsedUri); - if (containers != null) { - for (IContainer c : containers) { - if (c.isAccessible()) { - container = c; - break; - } - } - } - } - + IContainer container = findContainerForUri(uri); if (container == null) { return new ReadDirectoryResult(Collections.emptyList()); } @@ -373,9 +366,6 @@ public static ReadDirectoryResult readDirectoryEntries(String uri) { entries.add(new DirectoryEntry(member.getName(), type)); } return new ReadDirectoryResult(entries); - } catch (URISyntaxException e) { - CopilotCore.LOGGER.error("Invalid directory URI: " + uri, e); - return new ReadDirectoryResult(Collections.emptyList()); } catch (CoreException e) { CopilotCore.LOGGER.error("Failed to read directory: " + uri, e); return new ReadDirectoryResult(Collections.emptyList()); @@ -421,4 +411,271 @@ private static FileStat getFileStatFromEclipseResource(IFile file) { } return stat; } + + /** + * Finds files under the given base URI whose path (relative to the base container) matches the provided glob pattern. + * Used by the {@code workspace/findFiles} request so the language server can perform file search over custom URI + * schemes such as {@code semanticfs}. + * + * @param params the search parameters + * @return a {@link FindFilesResult} containing the matching file URIs + */ + public static FindFilesResult findFiles(FindFilesParams params) { + if (params == null || StringUtils.isBlank(params.getBaseUri()) || StringUtils.isBlank(params.getPattern())) { + return new FindFilesResult(List.of()); + } + + int maxResults = params.getMaxResults() != null && params.getMaxResults() > 0 ? params.getMaxResults() + : Integer.MAX_VALUE; + + try { + IContainer container = findContainerForUri(params.getBaseUri()); + if (container == null) { + CopilotCore.LOGGER.info("findFiles: base URI not found in workspace: " + params.getBaseUri()); + return new FindFilesResult(List.of()); + } + + PathMatcher matcher = FileSystems.getDefault().getPathMatcher("glob:" + params.getPattern()); + List uris = new ArrayList<>(); + IPath basePath = container.getFullPath(); + + collectMatchingFiles(container, basePath, matcher, uris, maxResults); + return new FindFilesResult(uris); + } catch (CoreException e) { + CopilotCore.LOGGER.error("Failed to find files under: " + params.getBaseUri(), e); + return new FindFilesResult(List.of()); + } catch (IllegalArgumentException e) { + CopilotCore.LOGGER.error("Invalid glob pattern for findFiles: " + params.getPattern(), e); + return new FindFilesResult(List.of()); + } + } + + private static void collectMatchingFiles(IContainer container, IPath basePath, PathMatcher matcher, + List results, int maxResults) throws CoreException { + if (results.size() >= maxResults) { + return; + } + for (IResource member : container.members()) { + if (results.size() >= maxResults) { + return; + } + if (member.getType() == IResource.FILE) { + IPath relative = member.getFullPath().makeRelativeTo(basePath); + // PathMatcher uses the platform default file system; convert to a java.nio.file.Path via + // the portable string so glob patterns like ** and *.ext work consistently. + Path nioPath = Paths.get(relative.toPortableString().replace('/', java.io.File.separatorChar)); + if (matcher.matches(nioPath) || matcher.matches(Paths.get(relative.toPortableString()))) { + String uri = getResourceUri(member); + if (uri != null) { + results.add(uri); + } + } + } else if (member instanceof IContainer) { + collectMatchingFiles((IContainer) member, basePath, matcher, results, maxResults); + } + } + } + + /** + * Searches for text (or a regex) in files under the given base URI. Used by the {@code workspace/findTextInFiles} + * request. + * + * @param params the search parameters + * @return a {@link FindTextInFilesResult} containing the matches + */ + public static FindTextInFilesResult findTextInFiles(FindTextInFilesParams params) { + if (params == null || StringUtils.isBlank(params.getBaseUri()) || StringUtils.isBlank(params.getQuery())) { + return new FindTextInFilesResult(List.of()); + } + + int maxResults = params.getMaxResults() != null && params.getMaxResults() > 0 ? params.getMaxResults() + : Integer.MAX_VALUE; + boolean isRegexp = Boolean.TRUE.equals(params.getIsRegexp()); + + Pattern pattern; + try { + pattern = isRegexp ? Pattern.compile(params.getQuery()) : Pattern.compile(Pattern.quote(params.getQuery())); + } catch (PatternSyntaxException e) { + CopilotCore.LOGGER.error("Invalid regex for findTextInFiles: " + params.getQuery(), e); + return new FindTextInFilesResult(List.of()); + } + + // Compile the optional include glob pattern to filter which files are searched + PathMatcher includeMatcher = null; + if (params.getIncludePattern() != null && !params.getIncludePattern().isEmpty()) { + try { + includeMatcher = FileSystems.getDefault().getPathMatcher("glob:" + params.getIncludePattern()); + } catch (IllegalArgumentException e) { + CopilotCore.LOGGER.error("Invalid glob for findTextInFiles includePattern: " + params.getIncludePattern(), e); + return new FindTextInFilesResult(List.of()); + } + } + + // Resolve the base URI to a workspace container and recursively search for text matches + try { + IContainer container = findContainerForUri(params.getBaseUri()); + if (container == null) { + CopilotCore.LOGGER.info("findTextInFiles: base URI not found in workspace: " + params.getBaseUri()); + return new FindTextInFilesResult(List.of()); + } + + List matches = new ArrayList<>(); + searchTextInContainer(container, container.getFullPath(), pattern, includeMatcher, matches, maxResults); + return new FindTextInFilesResult(matches); + } catch (CoreException e) { + CopilotCore.LOGGER.error("Failed to search text under: " + params.getBaseUri(), e); + return new FindTextInFilesResult(List.of()); + } + } + + private static void searchTextInContainer(IContainer container, IPath basePath, Pattern pattern, + @Nullable PathMatcher includeMatcher, List results, int maxResults) throws CoreException { + if (results.size() >= maxResults) { + return; + } + for (IResource member : container.members()) { + if (results.size() >= maxResults) { + return; + } + if (member.getType() == IResource.FILE) { + if (includeMatcher != null) { + IPath relative = member.getFullPath().makeRelativeTo(basePath); + Path nioPath = Paths.get(relative.toPortableString()); + if (!includeMatcher.matches(nioPath)) { + continue; + } + } + searchTextInFile((IFile) member, pattern, results, maxResults); + } else if (member instanceof IContainer) { + searchTextInContainer((IContainer) member, basePath, pattern, includeMatcher, results, maxResults); + } + } + } + + /** + * Default maximum number of characters for text search. Files exceeding this are skipped to avoid loading very large + * blobs into memory. + */ + private static final long TEXT_SEARCH_MAX_CHARS = 5L * 1024 * 1024; + + private static void searchTextInFile(IFile file, Pattern pattern, List results, int maxResults) { + String uri = getResourceUri(file); + if (uri == null) { + return; + } + try (InputStream is = file.getContents(true); + BufferedReader reader = new BufferedReader(new InputStreamReader(is, file.getCharset()))) { + long totalChars = 0; + String line; + int lineNumber = 0; + while ((line = reader.readLine()) != null) { + if (results.size() >= maxResults) { + return; + } + lineNumber++; + totalChars += line.length(); + if (totalChars > TEXT_SEARCH_MAX_CHARS) { + return; + } + Matcher m = pattern.matcher(line); + if (m.find()) { + results.add(new TextSearchMatch(uri, lineNumber, line)); + } + } + } catch (CoreException | IOException e) { + // Skip files we cannot read; other files may still yield matches. + CopilotCore.LOGGER.info("findTextInFiles: skipping unreadable file " + uri + ": " + e.getMessage()); + } + } + + /** + * Resolves a workspace container (folder/project/root) for the given URI, or {@code null} if none exists. Used by + * findFiles / findTextInFiles. + */ + @Nullable + private static IContainer findContainerForUri(String uri) { + if (StringUtils.isBlank(uri)) { + return null; + } + try { + URI parsedUri = new URI(uri); + IWorkspaceRoot root = ResourcesPlugin.getWorkspace().getRoot(); + + if ("platform".equals(parsedUri.getScheme())) { + String path = parsedUri.getPath(); + String prefix = "/resource"; + if (path != null && path.startsWith(prefix)) { + IResource resource = root.findMember(path.substring(prefix.length())); + if (resource instanceof IContainer c && c.isAccessible()) { + return c; + } + } + } + + IContainer[] containers = root.findContainersForLocationURI(parsedUri); + if (containers != null) { + for (IContainer c : containers) { + if (c != null && c.isAccessible()) { + return c; + } + } + } + } catch (URISyntaxException e) { + CopilotCore.LOGGER.error("Invalid container URI: " + uri, e); + } + return null; + } + + /** + * Resolves a workspace resource from a URI. Supports file URIs, platform resource URIs, and Eclipse-managed virtual + * URIs such as semanticfs. + * + * @param resourceUri the resource URI + * @return the matching workspace resource, or null if not found + */ + @Nullable + private static IResource getResourceFromUri(String resourceUri) { + if (StringUtils.isBlank(resourceUri)) { + return null; + } + + try { + URI uri = new URI(resourceUri); + IWorkspaceRoot root = ResourcesPlugin.getWorkspace().getRoot(); + + IFile[] files = root.findFilesForLocationURI(uri); + if (files != null) { + for (IFile file : files) { + if (file != null && file.exists()) { + return file; + } + } + } + + // Handle platform:/resource/... URIs by resolving via workspace path + if ("platform".equals(uri.getScheme())) { + String path = uri.getPath(); + String prefix = "/resource"; + if (path != null && path.startsWith(prefix)) { + IResource resource = root.findMember(path.substring(prefix.length())); + if (resource != null && resource.exists()) { + return resource; + } + } + } + + // For file://, semanticfs://, and other URIs, use location URI lookup + IContainer[] containers = root.findContainersForLocationURI(uri); + if (containers != null) { + for (IContainer container : containers) { + if (container != null && container.exists()) { + return container; + } + } + } + } catch (URISyntaxException e) { + CopilotCore.LOGGER.error("Invalid resource URI: " + resourceUri, e); + } + return null; + } } From efa1e6b0e00843dbb8b23fc8e5ffb5c46d5cd7c6 Mon Sep 17 00:00:00 2001 From: Ethan Hou Date: Thu, 23 Apr 2026 16:47:58 +0800 Subject: [PATCH 2/6] feat: use EFS for file operations to avoid locking the workspace resource-tree Co-authored-by: Copilot --- .../META-INF/MANIFEST.MF | 1 + .../copilot/eclipse/core/utils/FileUtils.java | 22 ++++++++++++++----- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/com.microsoft.copilot.eclipse.core/META-INF/MANIFEST.MF b/com.microsoft.copilot.eclipse.core/META-INF/MANIFEST.MF index 05f7823c..631965db 100644 --- a/com.microsoft.copilot.eclipse.core/META-INF/MANIFEST.MF +++ b/com.microsoft.copilot.eclipse.core/META-INF/MANIFEST.MF @@ -43,6 +43,7 @@ Require-Bundle: org.eclipse.lsp4e;bundle-version="0.18.1", org.eclipse.wildwebdeveloper.embedder.node;bundle-version="1.0.3";resolution:=optional, org.eclipse.core.net;bundle-version="1.5.200", org.eclipse.core.resources;bundle-version="3.20.0", + org.eclipse.core.filesystem;bundle-version="1.10.200", org.eclipse.core.runtime;bundle-version="[3.30.0,4.0.0)", org.apache.httpcomponents.client5.httpclient5;bundle-version="5.2.1", org.apache.httpcomponents.core5.httpcore5;bundle-version="5.2.3", diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/utils/FileUtils.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/utils/FileUtils.java index 99a3ec95..55a9f466 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/utils/FileUtils.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/utils/FileUtils.java @@ -26,6 +26,7 @@ import java.util.stream.Collectors; import org.apache.commons.lang3.StringUtils; +import org.eclipse.core.filesystem.EFS; import org.eclipse.core.resources.IContainer; import org.eclipse.core.resources.IFile; import org.eclipse.core.resources.IFolder; @@ -34,6 +35,7 @@ import org.eclipse.core.resources.ResourcesPlugin; import org.eclipse.core.runtime.CoreException; import org.eclipse.core.runtime.IPath; +import org.eclipse.core.runtime.NullProgressMonitor; import org.eclipse.jdt.annotation.Nullable; import org.eclipse.lsp4e.LSPEclipseUtils; @@ -373,7 +375,11 @@ public static ReadDirectoryResult readDirectoryEntries(String uri) { } private static String readFileContent(IFile file) throws CoreException, IOException { - try (InputStream is = file.getContents()) { + // Use EFS.getStore().openInputStream() instead of IFile.getContents() to avoid holding the + // Eclipse workspace resource-tree lock during the I/O. For virtual URI schemes (e.g. + // semanticfs://) IFile.getContents() would hold the lock across a synchronous network request, + // potentially stalling the UI thread. + try (InputStream is = EFS.getStore(file.getLocationURI()).openInputStream(EFS.NONE, new NullProgressMonitor())) { return new String(is.readAllBytes(), file.getCharset()); } } @@ -403,9 +409,10 @@ private static FileStat getFileStatFromEclipseResource(IFile file) { FileStat stat = new FileStat(); if (file.getLocationURI() != null) { - try (InputStream is = file.getContents(true)) { - stat.setSize(is.readAllBytes().length); - } catch (IOException | CoreException e) { + // Use EFS to query the file size without acquiring the workspace resource-tree lock. + try { + stat.setSize(EFS.getStore(file.getLocationURI()).fetchInfo().getLength()); + } catch (CoreException e) { // Ignore; size stays 0. } } @@ -563,7 +570,12 @@ private static void searchTextInFile(IFile file, Pattern pattern, List Date: Thu, 23 Apr 2026 17:06:43 +0800 Subject: [PATCH 3/6] refactor: convert FindFilesParams, FindFilesResult, FindTextInFilesParams, and FindTextInFilesResult to records for improved readability and maintainability Co-authored-by: Copilot --- .../core/lsp/protocol/FindFilesParams.java | 79 +------------ .../core/lsp/protocol/FindFilesResult.java | 51 +-------- .../lsp/protocol/FindTextInFilesParams.java | 108 ++---------------- .../lsp/protocol/FindTextInFilesResult.java | 50 +------- .../copilot/eclipse/core/utils/FileUtils.java | 36 +++--- 5 files changed, 37 insertions(+), 287 deletions(-) diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/FindFilesParams.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/FindFilesParams.java index b8e73136..eb95df76 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/FindFilesParams.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/FindFilesParams.java @@ -3,82 +3,13 @@ package com.microsoft.copilot.eclipse.core.lsp.protocol; -import java.util.Objects; - -import org.apache.commons.lang3.builder.ToStringBuilder; - /** * Parameters for the {@code workspace/findFiles} request. Used by the language server to ask the client to search for * files matching a glob pattern under a given base URI (e.g. a semanticfs workspace folder). + * + * @param baseUri the base URI to search under (e.g. a semanticfs workspace folder) + * @param pattern the glob pattern to match file paths against + * @param maxResults the maximum number of results to return (optional) */ -public class FindFilesParams { - - private String baseUri; - private String pattern; - private Integer maxResults; - - /** - * Constructs a new FindFilesParams object. - * - * @param baseUri the base URI to search under (e.g. a semanticfs workspace folder) - * @param pattern the glob pattern to match file paths against - * @param maxResults the maximum number of results to return (optional) - */ - public FindFilesParams(String baseUri, String pattern, Integer maxResults) { - this.baseUri = baseUri; - this.pattern = pattern; - this.maxResults = maxResults; - } - - public String getBaseUri() { - return baseUri; - } - - public void setBaseUri(String baseUri) { - this.baseUri = baseUri; - } - - public String getPattern() { - return pattern; - } - - public void setPattern(String pattern) { - this.pattern = pattern; - } - - public Integer getMaxResults() { - return maxResults; - } - - public void setMaxResults(Integer maxResults) { - this.maxResults = maxResults; - } - - @Override - public int hashCode() { - return Objects.hash(baseUri, pattern, maxResults); - } - - @Override - public boolean equals(Object obj) { - if (this == obj) { - return true; - } - if (obj == null || getClass() != obj.getClass()) { - return false; - } - FindFilesParams other = (FindFilesParams) obj; - return Objects.equals(baseUri, other.baseUri) && Objects.equals(pattern, other.pattern) - && Objects.equals(maxResults, other.maxResults); - } - - @Override - public String toString() { - ToStringBuilder builder = new ToStringBuilder(this); - builder.append("baseUri", baseUri); - builder.append("pattern", pattern); - builder.append("maxResults", maxResults); - return builder.toString(); - } - +public record FindFilesParams(String baseUri, String pattern, Integer maxResults) { } diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/FindFilesResult.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/FindFilesResult.java index a504e161..fc250a2e 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/FindFilesResult.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/FindFilesResult.java @@ -4,56 +4,11 @@ package com.microsoft.copilot.eclipse.core.lsp.protocol; import java.util.List; -import java.util.Objects; - -import org.apache.commons.lang3.builder.ToStringBuilder; /** * Result of the {@code workspace/findFiles} request, containing URIs of files matching the glob pattern. + * + * @param uris the list of file URIs matching the glob pattern */ -public class FindFilesResult { - - private List uris; - - /** - * Constructs a new FindFilesResult object. - * - * @param uris the list of file URIs matching the glob pattern - */ - public FindFilesResult(List uris) { - this.uris = uris; - } - - public List getUris() { - return uris; - } - - public void setUris(List uris) { - this.uris = uris; - } - - @Override - public int hashCode() { - return Objects.hash(uris); - } - - @Override - public boolean equals(Object obj) { - if (this == obj) { - return true; - } - if (obj == null || getClass() != obj.getClass()) { - return false; - } - FindFilesResult other = (FindFilesResult) obj; - return Objects.equals(uris, other.uris); - } - - @Override - public String toString() { - ToStringBuilder builder = new ToStringBuilder(this); - builder.append("uris", uris); - return builder.toString(); - } - +public record FindFilesResult(List uris) { } diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/FindTextInFilesParams.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/FindTextInFilesParams.java index f88cbdad..f6259d83 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/FindTextInFilesParams.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/FindTextInFilesParams.java @@ -3,108 +3,16 @@ package com.microsoft.copilot.eclipse.core.lsp.protocol; -import java.util.Objects; - -import org.apache.commons.lang3.builder.ToStringBuilder; - /** * Parameters for the {@code workspace/findTextInFiles} request. Used by the language server to ask the client to search * for text (or a regex) in files under a given base URI. + * + * @param baseUri the base URI to search under (e.g. a semanticfs workspace folder) + * @param query the text or regex pattern to search for in files + * @param isRegexp whether the query is a regular expression + * @param includePattern an optional glob pattern to filter which files to search + * @param maxResults the maximum number of results to return (optional) */ -public class FindTextInFilesParams { - - private String baseUri; - private String query; - private Boolean isRegexp; - private String includePattern; - private Integer maxResults; - - /** - * Constructs a new FindTextInFilesParams object. - * - * @param baseUri the base URI to search under (e.g. a semanticfs workspace folder) - * @param query the text or regex pattern to search for in files - * @param isRegexp whether the query is a regular expression - * @param includePattern an optional glob pattern to filter which files to search - * @param maxResults the maximum number of results to return (optional) - */ - public FindTextInFilesParams(String baseUri, String query, Boolean isRegexp, String includePattern, - Integer maxResults) { - this.baseUri = baseUri; - this.query = query; - this.isRegexp = isRegexp; - this.includePattern = includePattern; - this.maxResults = maxResults; - } - - public String getBaseUri() { - return baseUri; - } - - public void setBaseUri(String baseUri) { - this.baseUri = baseUri; - } - - public String getQuery() { - return query; - } - - public void setQuery(String query) { - this.query = query; - } - - public Boolean getIsRegexp() { - return isRegexp; - } - - public void setIsRegexp(Boolean isRegexp) { - this.isRegexp = isRegexp; - } - - public String getIncludePattern() { - return includePattern; - } - - public void setIncludePattern(String includePattern) { - this.includePattern = includePattern; - } - - public Integer getMaxResults() { - return maxResults; - } - - public void setMaxResults(Integer maxResults) { - this.maxResults = maxResults; - } - - @Override - public int hashCode() { - return Objects.hash(baseUri, query, isRegexp, includePattern, maxResults); - } - - @Override - public boolean equals(Object obj) { - if (this == obj) { - return true; - } - if (obj == null || getClass() != obj.getClass()) { - return false; - } - FindTextInFilesParams other = (FindTextInFilesParams) obj; - return Objects.equals(baseUri, other.baseUri) && Objects.equals(query, other.query) - && Objects.equals(isRegexp, other.isRegexp) && Objects.equals(includePattern, other.includePattern) - && Objects.equals(maxResults, other.maxResults); - } - - @Override - public String toString() { - ToStringBuilder builder = new ToStringBuilder(this); - builder.append("baseUri", baseUri); - builder.append("query", query); - builder.append("isRegexp", isRegexp); - builder.append("includePattern", includePattern); - builder.append("maxResults", maxResults); - return builder.toString(); - } - +public record FindTextInFilesParams(String baseUri, String query, Boolean isRegexp, String includePattern, + Integer maxResults) { } diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/FindTextInFilesResult.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/FindTextInFilesResult.java index b4819b02..7e7bd0b6 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/FindTextInFilesResult.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/FindTextInFilesResult.java @@ -4,57 +4,13 @@ package com.microsoft.copilot.eclipse.core.lsp.protocol; import java.util.List; -import java.util.Objects; - -import org.apache.commons.lang3.builder.ToStringBuilder; /** * Result of the {@code workspace/findTextInFiles} request, containing the list of matches. + * + * @param matches the list of text search matches */ -public class FindTextInFilesResult { - - private List matches; - - /** - * Constructs a new FindTextInFilesResult object. - * - * @param matches the list of text search matches - */ - public FindTextInFilesResult(List matches) { - this.matches = matches; - } - - public List getMatches() { - return matches; - } - - public void setMatches(List matches) { - this.matches = matches; - } - - @Override - public int hashCode() { - return Objects.hash(matches); - } - - @Override - public boolean equals(Object obj) { - if (this == obj) { - return true; - } - if (obj == null || getClass() != obj.getClass()) { - return false; - } - FindTextInFilesResult other = (FindTextInFilesResult) obj; - return Objects.equals(matches, other.matches); - } - - @Override - public String toString() { - ToStringBuilder builder = new ToStringBuilder(this); - builder.append("matches", matches); - return builder.toString(); - } +public record FindTextInFilesResult(List matches) { /** * A single text search match. Field names mirror the CLS protocol. diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/utils/FileUtils.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/utils/FileUtils.java index 55a9f466..c6061a1e 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/utils/FileUtils.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/utils/FileUtils.java @@ -428,31 +428,31 @@ private static FileStat getFileStatFromEclipseResource(IFile file) { * @return a {@link FindFilesResult} containing the matching file URIs */ public static FindFilesResult findFiles(FindFilesParams params) { - if (params == null || StringUtils.isBlank(params.getBaseUri()) || StringUtils.isBlank(params.getPattern())) { + if (params == null || StringUtils.isBlank(params.baseUri()) || StringUtils.isBlank(params.pattern())) { return new FindFilesResult(List.of()); } - int maxResults = params.getMaxResults() != null && params.getMaxResults() > 0 ? params.getMaxResults() + int maxResults = params.maxResults() != null && params.maxResults() > 0 ? params.maxResults() : Integer.MAX_VALUE; try { - IContainer container = findContainerForUri(params.getBaseUri()); + IContainer container = findContainerForUri(params.baseUri()); if (container == null) { - CopilotCore.LOGGER.info("findFiles: base URI not found in workspace: " + params.getBaseUri()); + CopilotCore.LOGGER.info("findFiles: base URI not found in workspace: " + params.baseUri()); return new FindFilesResult(List.of()); } - PathMatcher matcher = FileSystems.getDefault().getPathMatcher("glob:" + params.getPattern()); + PathMatcher matcher = FileSystems.getDefault().getPathMatcher("glob:" + params.pattern()); List uris = new ArrayList<>(); IPath basePath = container.getFullPath(); collectMatchingFiles(container, basePath, matcher, uris, maxResults); return new FindFilesResult(uris); } catch (CoreException e) { - CopilotCore.LOGGER.error("Failed to find files under: " + params.getBaseUri(), e); + CopilotCore.LOGGER.error("Failed to find files under: " + params.baseUri(), e); return new FindFilesResult(List.of()); } catch (IllegalArgumentException e) { - CopilotCore.LOGGER.error("Invalid glob pattern for findFiles: " + params.getPattern(), e); + CopilotCore.LOGGER.error("Invalid glob pattern for findFiles: " + params.pattern(), e); return new FindFilesResult(List.of()); } } @@ -491,38 +491,38 @@ private static void collectMatchingFiles(IContainer container, IPath basePath, P * @return a {@link FindTextInFilesResult} containing the matches */ public static FindTextInFilesResult findTextInFiles(FindTextInFilesParams params) { - if (params == null || StringUtils.isBlank(params.getBaseUri()) || StringUtils.isBlank(params.getQuery())) { + if (params == null || StringUtils.isBlank(params.baseUri()) || StringUtils.isBlank(params.query())) { return new FindTextInFilesResult(List.of()); } - int maxResults = params.getMaxResults() != null && params.getMaxResults() > 0 ? params.getMaxResults() + int maxResults = params.maxResults() != null && params.maxResults() > 0 ? params.maxResults() : Integer.MAX_VALUE; - boolean isRegexp = Boolean.TRUE.equals(params.getIsRegexp()); + boolean isRegexp = Boolean.TRUE.equals(params.isRegexp()); Pattern pattern; try { - pattern = isRegexp ? Pattern.compile(params.getQuery()) : Pattern.compile(Pattern.quote(params.getQuery())); + pattern = isRegexp ? Pattern.compile(params.query()) : Pattern.compile(Pattern.quote(params.query())); } catch (PatternSyntaxException e) { - CopilotCore.LOGGER.error("Invalid regex for findTextInFiles: " + params.getQuery(), e); + CopilotCore.LOGGER.error("Invalid regex for findTextInFiles: " + params.query(), e); return new FindTextInFilesResult(List.of()); } // Compile the optional include glob pattern to filter which files are searched PathMatcher includeMatcher = null; - if (params.getIncludePattern() != null && !params.getIncludePattern().isEmpty()) { + if (params.includePattern() != null && !params.includePattern().isEmpty()) { try { - includeMatcher = FileSystems.getDefault().getPathMatcher("glob:" + params.getIncludePattern()); + includeMatcher = FileSystems.getDefault().getPathMatcher("glob:" + params.includePattern()); } catch (IllegalArgumentException e) { - CopilotCore.LOGGER.error("Invalid glob for findTextInFiles includePattern: " + params.getIncludePattern(), e); + CopilotCore.LOGGER.error("Invalid glob for findTextInFiles includePattern: " + params.includePattern(), e); return new FindTextInFilesResult(List.of()); } } // Resolve the base URI to a workspace container and recursively search for text matches try { - IContainer container = findContainerForUri(params.getBaseUri()); + IContainer container = findContainerForUri(params.baseUri()); if (container == null) { - CopilotCore.LOGGER.info("findTextInFiles: base URI not found in workspace: " + params.getBaseUri()); + CopilotCore.LOGGER.info("findTextInFiles: base URI not found in workspace: " + params.baseUri()); return new FindTextInFilesResult(List.of()); } @@ -530,7 +530,7 @@ public static FindTextInFilesResult findTextInFiles(FindTextInFilesParams params searchTextInContainer(container, container.getFullPath(), pattern, includeMatcher, matches, maxResults); return new FindTextInFilesResult(matches); } catch (CoreException e) { - CopilotCore.LOGGER.error("Failed to search text under: " + params.getBaseUri(), e); + CopilotCore.LOGGER.error("Failed to search text under: " + params.baseUri(), e); return new FindTextInFilesResult(List.of()); } } From 54a0d47161b4f30d02244d92f1b151ee238cdb8d Mon Sep 17 00:00:00 2001 From: Ethan Hou Date: Thu, 23 Apr 2026 17:26:00 +0800 Subject: [PATCH 4/6] Address comments. Co-authored-by: Copilot --- .../copilot/eclipse/core/utils/FileUtils.java | 54 ++++++++++++++++--- 1 file changed, 46 insertions(+), 8 deletions(-) diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/utils/FileUtils.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/utils/FileUtils.java index c6061a1e..7ee6af10 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/utils/FileUtils.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/utils/FileUtils.java @@ -266,12 +266,27 @@ public static IFile getFileFromPath(String filePath, boolean checkExistence) { return null; } - // Try URI-based resolution first for non-filesystem URI schemes (e.g., semanticfs://) - if (URI_SCHEME_PATTERN.matcher(filePath).find() && !filePath.startsWith("file:")) { + // Try URI-based resolution first for non-filesystem URI schemes (e.g., semanticfs://). + // Exclude drive-letter paths (e.g., C:/project/file.txt) — they match the URI_SCHEME_PATTERN + // but must be handled as filesystem paths below. + if (URI_SCHEME_PATTERN.matcher(filePath).find() && !filePath.startsWith("file:") && !hasDriveLetter(filePath)) { IResource resource = getResourceFromUri(filePath); if (resource instanceof IFile file) { return file; } + // getResourceFromUri only returns existing resources. When checkExistence=false, try to + // obtain an IFile handle without requiring the resource to already exist on disk. + if (!checkExistence) { + try { + URI uri = new URI(filePath); + IFile[] files = ResourcesPlugin.getWorkspace().getRoot().findFilesForLocationURI(uri); + if (files != null && files.length > 0 && files[0] != null) { + return files[0]; + } + } catch (URISyntaxException e) { + CopilotCore.LOGGER.error("Invalid URI in getFileFromPath: " + filePath, e); + } + } return null; } @@ -375,11 +390,19 @@ public static ReadDirectoryResult readDirectoryEntries(String uri) { } private static String readFileContent(IFile file) throws CoreException, IOException { + URI locationUri = file.getLocationURI(); + if (locationUri == null) { + // IResource#getLocationURI() can be null for resources without a defined location. + // Fall back to IFile.getContents() which works for any local resource. + try (InputStream is = file.getContents()) { + return new String(is.readAllBytes(), file.getCharset()); + } + } // Use EFS.getStore().openInputStream() instead of IFile.getContents() to avoid holding the // Eclipse workspace resource-tree lock during the I/O. For virtual URI schemes (e.g. // semanticfs://) IFile.getContents() would hold the lock across a synchronous network request, // potentially stalling the UI thread. - try (InputStream is = EFS.getStore(file.getLocationURI()).openInputStream(EFS.NONE, new NullProgressMonitor())) { + try (InputStream is = EFS.getStore(locationUri).openInputStream(EFS.NONE, new NullProgressMonitor())) { return new String(is.readAllBytes(), file.getCharset()); } } @@ -432,8 +455,9 @@ public static FindFilesResult findFiles(FindFilesParams params) { return new FindFilesResult(List.of()); } - int maxResults = params.maxResults() != null && params.maxResults() > 0 ? params.maxResults() - : Integer.MAX_VALUE; + int maxResults = params.maxResults() != null && params.maxResults() > 0 + ? Math.min(params.maxResults(), MAX_SEARCH_RESULTS) + : MAX_SEARCH_RESULTS; try { IContainer container = findContainerForUri(params.baseUri()); @@ -495,8 +519,9 @@ public static FindTextInFilesResult findTextInFiles(FindTextInFilesParams params return new FindTextInFilesResult(List.of()); } - int maxResults = params.maxResults() != null && params.maxResults() > 0 ? params.maxResults() - : Integer.MAX_VALUE; + int maxResults = params.maxResults() != null && params.maxResults() > 0 + ? Math.min(params.maxResults(), MAX_SEARCH_RESULTS) + : MAX_SEARCH_RESULTS; boolean isRegexp = Boolean.TRUE.equals(params.isRegexp()); Pattern pattern; @@ -559,6 +584,13 @@ private static void searchTextInContainer(IContainer container, IPath basePath, } } + /** + * Upper bound on the number of results returned by {@link #findFiles} and {@link #findTextInFiles} when the caller + * does not specify {@code maxResults} or specifies a value exceeding this limit. Capping the default prevents + * accidental out-of-memory conditions and excessively long workspace traversals on large projects. + */ + private static final int MAX_SEARCH_RESULTS = 20; + /** * Default maximum number of characters for text search. Files exceeding this are skipped to avoid loading very large * blobs into memory. @@ -570,12 +602,18 @@ private static void searchTextInFile(IFile file, Pattern pattern, List Date: Fri, 24 Apr 2026 14:35:15 +0800 Subject: [PATCH 5/6] fix: make regex search case insensitive in findTextInFiles --- .../com/microsoft/copilot/eclipse/core/utils/FileUtils.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/utils/FileUtils.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/utils/FileUtils.java index 7ee6af10..dadb967d 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/utils/FileUtils.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/utils/FileUtils.java @@ -526,7 +526,8 @@ public static FindTextInFilesResult findTextInFiles(FindTextInFilesParams params Pattern pattern; try { - pattern = isRegexp ? Pattern.compile(params.query()) : Pattern.compile(Pattern.quote(params.query())); + pattern = isRegexp ? Pattern.compile(params.query(), Pattern.CASE_INSENSITIVE) + : Pattern.compile(Pattern.quote(params.query()), Pattern.CASE_INSENSITIVE); } catch (PatternSyntaxException e) { CopilotCore.LOGGER.error("Invalid regex for findTextInFiles: " + params.query(), e); return new FindTextInFilesResult(List.of()); From b5bbfb08a7b458a0496c9aca687fab950d45b104 Mon Sep 17 00:00:00 2001 From: Ethan Hou Date: Tue, 28 Apr 2026 10:42:40 +0800 Subject: [PATCH 6/6] Address comments. Co-authored-by: Copilot --- .../core/lsp/protocol/FindFilesParams.java | 2 +- .../lsp/protocol/FindTextInFilesParams.java | 2 +- .../copilot/eclipse/core/utils/FileUtils.java | 379 ++++++++++-------- 3 files changed, 217 insertions(+), 166 deletions(-) diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/FindFilesParams.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/FindFilesParams.java index eb95df76..0e3f59ce 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/FindFilesParams.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/FindFilesParams.java @@ -11,5 +11,5 @@ * @param pattern the glob pattern to match file paths against * @param maxResults the maximum number of results to return (optional) */ -public record FindFilesParams(String baseUri, String pattern, Integer maxResults) { +public record FindFilesParams(String baseUri, String pattern, int maxResults) { } diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/FindTextInFilesParams.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/FindTextInFilesParams.java index f6259d83..84bb5832 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/FindTextInFilesParams.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/FindTextInFilesParams.java @@ -14,5 +14,5 @@ * @param maxResults the maximum number of results to return (optional) */ public record FindTextInFilesParams(String baseUri, String query, Boolean isRegexp, String includePattern, - Integer maxResults) { + int maxResults) { } diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/utils/FileUtils.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/utils/FileUtils.java index dadb967d..3dd51c39 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/utils/FileUtils.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/utils/FileUtils.java @@ -60,6 +60,12 @@ public class FileUtils { private static final Pattern URI_SCHEME_PATTERN = Pattern.compile("^\\w[\\w\\d+.-]*:/"); + /** + * Upper bound on the number of results returned by {@link #findFiles} and {@link #findTextInFiles} when the caller + * does not specify {@code maxResults} or specifies a value exceeding this limit. This value aligns with the CLS. + */ + private static final int MAX_SEARCH_RESULTS = 20; + private FileUtils() { } @@ -216,39 +222,6 @@ public static String normalizeToUri(String pathOrUri) { return uri.toString(); } - /** - * Resolves a file path to a URI. Handles Windows absolute paths, POSIX absolute paths, and existing URI strings. - * - * @param filepath the file path to resolve - * @return the resolved URI, or null if the path is invalid - */ - private static URI resolvePathToUri(String filepath) { - // Check for POSIX-like absolute paths or Windows-like absolute paths - if (filepath.startsWith("/") - || hasDriveLetter(filepath) - || (PlatformUtils.isWindows() && filepath.startsWith("\\"))) { - try { - return Paths.get(filepath).toUri(); - } catch (Exception e) { - CopilotCore.LOGGER.error("Failed to convert path to URI: " + filepath, e); - return null; - } - } - - // Check if the filepath starts with a URI scheme (e.g., file:, http:) - // Verify the character after colon is "/" to distinguish from Windows drive letters - if (URI_SCHEME_PATTERN.matcher(filepath).find()) { - try { - return new URI(filepath); - } catch (URISyntaxException e) { - CopilotCore.LOGGER.error("Failed to parse URI: " + filepath, e); - return null; - } - } - - return null; - } - /** * Get an IFile from a file path string. This method tries multiple approaches to locate the file in the workspace: 1. * First tries getFileForLocation for absolute filesystem paths 2. Falls back to getFile for workspace-relative paths @@ -311,16 +284,6 @@ public static IFile getFileFromPath(String filePath, boolean checkExistence) { return null; } - /** - * Checks if the filepath starts with a Windows drive letter (e.g., C:). - * - * @param filepath the file path to check - * @return true if the path starts with a drive letter, false otherwise - */ - private static boolean hasDriveLetter(String filepath) { - return filepath.length() > 1 && Character.isLetter(filepath.charAt(0)) && filepath.charAt(1) == ':'; - } - /** * Reads the contents and stats of a file given its URI. Used by workspace/readFile API to read file content along * with file stats using uri. @@ -389,59 +352,6 @@ public static ReadDirectoryResult readDirectoryEntries(String uri) { } } - private static String readFileContent(IFile file) throws CoreException, IOException { - URI locationUri = file.getLocationURI(); - if (locationUri == null) { - // IResource#getLocationURI() can be null for resources without a defined location. - // Fall back to IFile.getContents() which works for any local resource. - try (InputStream is = file.getContents()) { - return new String(is.readAllBytes(), file.getCharset()); - } - } - // Use EFS.getStore().openInputStream() instead of IFile.getContents() to avoid holding the - // Eclipse workspace resource-tree lock during the I/O. For virtual URI schemes (e.g. - // semanticfs://) IFile.getContents() would hold the lock across a synchronous network request, - // potentially stalling the UI thread. - try (InputStream is = EFS.getStore(locationUri).openInputStream(EFS.NONE, new NullProgressMonitor())) { - return new String(is.readAllBytes(), file.getCharset()); - } - } - - private static FileStat getFileStatFromFile(IFile file) { - // Prefer local filesystem metadata when available. - if (file.getLocation() != null) { - return getFileStatFromPath(file.getLocation().toFile().toPath()); - } - - // Fall back to Eclipse resource metadata. - return getFileStatFromEclipseResource(file); - } - - private static FileStat getFileStatFromPath(Path path) { - FileStat stat = new FileStat(); - try { - BasicFileAttributes attrs = Files.readAttributes(path, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); - stat.setSize(attrs.size()); - } catch (IOException e) { - CopilotCore.LOGGER.error("Failed to read file size attribute", e); - } - return stat; - } - - private static FileStat getFileStatFromEclipseResource(IFile file) { - FileStat stat = new FileStat(); - - if (file.getLocationURI() != null) { - // Use EFS to query the file size without acquiring the workspace resource-tree lock. - try { - stat.setSize(EFS.getStore(file.getLocationURI()).fetchInfo().getLength()); - } catch (CoreException e) { - // Ignore; size stays 0. - } - } - return stat; - } - /** * Finds files under the given base URI whose path (relative to the base container) matches the provided glob pattern. * Used by the {@code workspace/findFiles} request so the language server can perform file search over custom URI @@ -455,9 +365,7 @@ public static FindFilesResult findFiles(FindFilesParams params) { return new FindFilesResult(List.of()); } - int maxResults = params.maxResults() != null && params.maxResults() > 0 - ? Math.min(params.maxResults(), MAX_SEARCH_RESULTS) - : MAX_SEARCH_RESULTS; + int maxResults = resolveMaxResults(params.maxResults()); try { IContainer container = findContainerForUri(params.baseUri()); @@ -470,7 +378,19 @@ public static FindFilesResult findFiles(FindFilesParams params) { List uris = new ArrayList<>(); IPath basePath = container.getFullPath(); - collectMatchingFiles(container, basePath, matcher, uris, maxResults); + // Narrow the starting container to the literal prefix of the glob to skip unrelated + // subtrees (e.g. node_modules/, .git/, target/). + IContainer startContainer = narrowToLiteralPrefix(container, params.pattern()); + if (startContainer == null) { + return new FindFilesResult(List.of()); + } + + walkFiles(startContainer, basePath, matcher, file -> { + String uri = getResourceUri(file); + if (uri != null) { + uris.add(uri); + } + }, uris, maxResults); return new FindFilesResult(uris); } catch (CoreException e) { CopilotCore.LOGGER.error("Failed to find files under: " + params.baseUri(), e); @@ -481,32 +401,6 @@ public static FindFilesResult findFiles(FindFilesParams params) { } } - private static void collectMatchingFiles(IContainer container, IPath basePath, PathMatcher matcher, - List results, int maxResults) throws CoreException { - if (results.size() >= maxResults) { - return; - } - for (IResource member : container.members()) { - if (results.size() >= maxResults) { - return; - } - if (member.getType() == IResource.FILE) { - IPath relative = member.getFullPath().makeRelativeTo(basePath); - // PathMatcher uses the platform default file system; convert to a java.nio.file.Path via - // the portable string so glob patterns like ** and *.ext work consistently. - Path nioPath = Paths.get(relative.toPortableString().replace('/', java.io.File.separatorChar)); - if (matcher.matches(nioPath) || matcher.matches(Paths.get(relative.toPortableString()))) { - String uri = getResourceUri(member); - if (uri != null) { - results.add(uri); - } - } - } else if (member instanceof IContainer) { - collectMatchingFiles((IContainer) member, basePath, matcher, results, maxResults); - } - } - } - /** * Searches for text (or a regex) in files under the given base URI. Used by the {@code workspace/findTextInFiles} * request. @@ -519,9 +413,7 @@ public static FindTextInFilesResult findTextInFiles(FindTextInFilesParams params return new FindTextInFilesResult(List.of()); } - int maxResults = params.maxResults() != null && params.maxResults() > 0 - ? Math.min(params.maxResults(), MAX_SEARCH_RESULTS) - : MAX_SEARCH_RESULTS; + int maxResults = resolveMaxResults(params.maxResults()); boolean isRegexp = Boolean.TRUE.equals(params.isRegexp()); Pattern pattern; @@ -552,8 +444,20 @@ public static FindTextInFilesResult findTextInFiles(FindTextInFilesParams params return new FindTextInFilesResult(List.of()); } + // Narrow the starting container using the include glob's literal prefix when available. + IContainer startContainer = container; + if (includeMatcher != null) { + IContainer narrowed = narrowToLiteralPrefix(container, params.includePattern()); + if (narrowed == null) { + return new FindTextInFilesResult(List.of()); + } + startContainer = narrowed; + } + List matches = new ArrayList<>(); - searchTextInContainer(container, container.getFullPath(), pattern, includeMatcher, matches, maxResults); + walkFiles(startContainer, container.getFullPath(), includeMatcher, file -> { + searchTextInFile(file, pattern, matches, maxResults); + }, matches, maxResults); return new FindTextInFilesResult(matches); } catch (CoreException e) { CopilotCore.LOGGER.error("Failed to search text under: " + params.baseUri(), e); @@ -561,42 +465,101 @@ public static FindTextInFilesResult findTextInFiles(FindTextInFilesParams params } } - private static void searchTextInContainer(IContainer container, IPath basePath, Pattern pattern, - @Nullable PathMatcher includeMatcher, List results, int maxResults) throws CoreException { - if (results.size() >= maxResults) { - return; - } - for (IResource member : container.members()) { - if (results.size() >= maxResults) { - return; + /** + * Resolves a file path to a URI. Handles Windows absolute paths, POSIX absolute paths, and existing URI strings. + * + * @param filepath the file path to resolve + * @return the resolved URI, or null if the path is invalid + */ + private static URI resolvePathToUri(String filepath) { + // Check for POSIX-like absolute paths or Windows-like absolute paths + if (filepath.startsWith("/") + || hasDriveLetter(filepath) + || (PlatformUtils.isWindows() && filepath.startsWith("\\"))) { + try { + return Paths.get(filepath).toUri(); + } catch (Exception e) { + CopilotCore.LOGGER.error("Failed to convert path to URI: " + filepath, e); + return null; } - if (member.getType() == IResource.FILE) { - if (includeMatcher != null) { - IPath relative = member.getFullPath().makeRelativeTo(basePath); - Path nioPath = Paths.get(relative.toPortableString()); - if (!includeMatcher.matches(nioPath)) { - continue; - } - } - searchTextInFile((IFile) member, pattern, results, maxResults); - } else if (member instanceof IContainer) { - searchTextInContainer((IContainer) member, basePath, pattern, includeMatcher, results, maxResults); + } + + // Check if the filepath starts with a URI scheme (e.g., file:, http:) + // Verify the character after colon is "/" to distinguish from Windows drive letters + if (URI_SCHEME_PATTERN.matcher(filepath).find()) { + try { + return new URI(filepath); + } catch (URISyntaxException e) { + CopilotCore.LOGGER.error("Failed to parse URI: " + filepath, e); + return null; } } + + return null; } /** - * Upper bound on the number of results returned by {@link #findFiles} and {@link #findTextInFiles} when the caller - * does not specify {@code maxResults} or specifies a value exceeding this limit. Capping the default prevents - * accidental out-of-memory conditions and excessively long workspace traversals on large projects. + * Checks if the filepath starts with a Windows drive letter (e.g., C:). + * + * @param filepath the file path to check + * @return true if the path starts with a drive letter, false otherwise */ - private static final int MAX_SEARCH_RESULTS = 20; + private static boolean hasDriveLetter(String filepath) { + return filepath.length() > 1 && Character.isLetter(filepath.charAt(0)) && filepath.charAt(1) == ':'; + } - /** - * Default maximum number of characters for text search. Files exceeding this are skipped to avoid loading very large - * blobs into memory. - */ - private static final long TEXT_SEARCH_MAX_CHARS = 5L * 1024 * 1024; + private static String readFileContent(IFile file) throws CoreException, IOException { + URI locationUri = file.getLocationURI(); + if (locationUri == null) { + // IResource#getLocationURI() can be null for resources without a defined location. + // Fall back to IFile.getContents() which works for any local resource. + try (InputStream is = file.getContents()) { + return new String(is.readAllBytes(), file.getCharset()); + } + } + // Use EFS.getStore().openInputStream() instead of IFile.getContents() to avoid holding the + // Eclipse workspace resource-tree lock during the I/O. For virtual URI schemes (e.g. + // semanticfs://) IFile.getContents() would hold the lock across a synchronous network request, + // potentially stalling the UI thread. + try (InputStream is = EFS.getStore(locationUri).openInputStream(EFS.NONE, new NullProgressMonitor())) { + return new String(is.readAllBytes(), file.getCharset()); + } + } + + private static FileStat getFileStatFromFile(IFile file) { + // Prefer local filesystem metadata when available. + if (file.getLocation() != null) { + return getFileStatFromPath(file.getLocation().toFile().toPath()); + } + + // Fall back to Eclipse resource metadata. + return getFileStatFromEclipseResource(file); + } + + private static FileStat getFileStatFromPath(Path path) { + FileStat stat = new FileStat(); + try { + BasicFileAttributes attrs = Files.readAttributes(path, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); + stat.setSize(attrs.size()); + } catch (IOException e) { + CopilotCore.LOGGER.error("Failed to read file size attribute", e); + } + return stat; + } + + private static FileStat getFileStatFromEclipseResource(IFile file) { + FileStat stat = new FileStat(); + + if (file.getLocationURI() != null) { + // Use EFS to query the file size without acquiring the workspace resource-tree lock. + try { + stat.setSize(EFS.getStore(file.getLocationURI()).fetchInfo().getLength()); + } catch (CoreException e) { + // Ignore; size stays 0. + } + } + return stat; + } private static void searchTextInFile(IFile file, Pattern pattern, List results, int maxResults) { String uri = getResourceUri(file); @@ -616,7 +579,6 @@ private static void searchTextInFile(IFile file, Pattern pattern, List TEXT_SEARCH_MAX_CHARS) { - return; - } Matcher m = pattern.matcher(line); if (m.find()) { results.add(new TextSearchMatch(uri, lineNumber, line)); } } } catch (CoreException | IOException e) { - // Skip files we cannot read; other files may still yield matches. CopilotCore.LOGGER.info("findTextInFiles: skipping unreadable file " + uri + ": " + e.getMessage()); } } + /** + * Narrows a base container to the subcontainer matching the literal directory prefix of a glob pattern. Returns the + * narrowed container, or the original container if no literal prefix exists, or {@code null} if the prefix path does + * not exist in the workspace (meaning no files can match). + */ + @Nullable + private static IContainer narrowToLiteralPrefix(IContainer base, String glob) { + String prefix = extractGlobLiteralPrefix(glob); + if (prefix.isEmpty()) { + return base; + } + IResource member = base.findMember(prefix); + if (member instanceof IContainer c && c.isAccessible()) { + return c; + } + // If member exists but is not a container (e.g. a file) — fall back to the base. + // Else if member is null — the prefix path does not exist so no files can match. + return member != null ? base : null; + } + + /** + * Extracts the literal directory prefix from a glob pattern — the longest sequence of complete path segments that + * contain no wildcard characters ({@code *}, {@code ?}, {, {@code [}). For example, + * {@code src/main/java/**\/*.java} yields {@code src/main/java}. + */ + private static String extractGlobLiteralPrefix(String glob) { + StringBuilder prefix = new StringBuilder(); + for (String segment : glob.split("/")) { + if (segment.contains("*") || segment.contains("?") || segment.contains("{") || segment.contains("[")) { + break; + } + if (prefix.length() > 0) { + prefix.append('/'); + } + prefix.append(segment); + } + return prefix.toString(); + } + + /** + * Resolves the effective maximum number of search results, capping at {@link #MAX_SEARCH_RESULTS}. + */ + private static int resolveMaxResults(int requested) { + return requested > 0 ? Math.min(requested, MAX_SEARCH_RESULTS) : MAX_SEARCH_RESULTS; + } + + /** + * Callback interface for {@link #walkFiles}. Invoked for each file whose path matches the glob filter. + */ + @FunctionalInterface + private interface FileVisitor { + void visit(IFile file) throws CoreException; + } + + /** + * Recursively walks files under {@code container}, skipping derived/hidden/team-private resources. For each file + * whose relative path matches {@code fileMatcher} (when non-null), the {@code visitor} is invoked. The walk stops + * once {@code results.size() >= maxResults}. + */ + private static void walkFiles(IContainer container, IPath basePath, @Nullable PathMatcher fileMatcher, + FileVisitor visitor, List results, int maxResults) throws CoreException { + if (results.size() >= maxResults) { + return; + } + for (IResource member : container.members()) { + if (results.size() >= maxResults) { + return; + } + if (shouldSkipResource(member)) { + continue; + } + if (member.getType() == IResource.FILE) { + IPath relative = member.getFullPath().makeRelativeTo(basePath); + // Glob patterns use '/'; match against the portable (forward-slash) form. + // Paths.get normalizes separators appropriately for the default FileSystem. + if (fileMatcher != null && !fileMatcher.matches(Paths.get(relative.toPortableString()))) { + continue; + } + visitor.visit((IFile) member); + } else if (member instanceof IContainer c) { + walkFiles(c, basePath, fileMatcher, visitor, results, maxResults); + } + } + } + + /** + * Returns {@code true} if the resource should be excluded from search traversal. Skips build output (derived), + * version-control internals (team-private), and hidden resources. + */ + private static boolean shouldSkipResource(IResource resource) { + return resource.isDerived(IResource.CHECK_ANCESTORS) || resource.isTeamPrivateMember(IResource.CHECK_ANCESTORS) + || resource.isHidden(IResource.CHECK_ANCESTORS); + } + /** * Resolves a workspace container (folder/project/root) for the given URI, or {@code null} if none exists. Used by * findFiles / findTextInFiles.