Skip to content

Commit a2f3125

Browse files
authored
Fix didopen for build files (#199)
#168 started tracking build file changes via lifecycle methods, didOpen, etc. But it didn't make a distinction between what was a build file, and what was a Smithy file. There are two paths didOpen can take - the first is when the file being opened is known to be part of a project. In this case, the file is already tracked by its owning Project, so its basically a no-op. The second path is when the file does not belong to any project, in which case we created a "detached" project, which is a project with no build files and just a single Smithy file. So if you opened a build file that wasn't known to be part of a Project, the language server tried to make a detached project containing the build file as a smithy file. This is obviously wrong, but wasn't observable to clients AFAICT because clients weren't set up to send requests to the server for build files (specifically, you wouldn't get diagnostics or anything, only for .smithy files). However, recent commits, including #188, now want to provide language support for smithy-build.json. In testing these new commits with local changes to have VSCode send requests for smithy-build.json, the issue could be observed. Specifically, the issue happens when you open a new smithy-build.json before we receive the didChangeWatchedFiles notification that tells us a new build file was created. didChangeWatchedFiles is the way we actually updated the state of projects to include new build files, or create new Projects. Since we can receive didOpen for a build file before didChangeWatchedFiles, we needed to do something with the build file on didOpen. This commit addresses the problem by adding a new Project type, UNRESOLVED, which is a project containing a single build file that no existing projects are aware of. We do this by modifying the didOpen path when the file isn't known to any project, checking if it is a build file using a PathMatcher, and if it is, creating an unresolved project for it. Then, when we load projects following a didChangeWatchedFiles, we just drop any unresolved projects with the same path as any of the build files in the newly loaded projects (see ServerState::resolveProjects). I also made some (upgrades?) to FilePatterns to better handle the discrepancy between matching behavior of PathMatchers and clients (see #191). Now there are (private) *PatternOptions enums that FilePatterns uses to configure the pattern for different use cases. For example, the new FilePatterns::getSmithyFileWatchPathMatchers provides a list of PathMatchers which should match the same paths as the watcher patterns we send back to clients, which is useful for testing. I also fixed an issue where parsing an empty build file would cause an NPE when trying to map validation events to ranges. Document::rangeBetween would return null if the document was empty, but I wasn't checking for that in ToSmithyNode (which creates parse events). The reason the range is null is because Document.lineOfIndex returns oob for an index of 0 into an empty document. Makes sense, as an empty document has no lines. I updated a DocumentTest to clarify this behavior. By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.
1 parent b124b3d commit a2f3125

File tree

12 files changed

+532
-109
lines changed

12 files changed

+532
-109
lines changed

src/main/java/software/amazon/smithy/lsp/FilePatterns.java

Lines changed: 66 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import java.nio.file.FileSystems;
1010
import java.nio.file.Path;
1111
import java.nio.file.PathMatcher;
12+
import java.util.EnumSet;
1213
import java.util.List;
1314
import java.util.stream.Collectors;
1415
import java.util.stream.Stream;
@@ -20,16 +21,37 @@
2021
* or build files in Projects and workspaces.
2122
*/
2223
final class FilePatterns {
24+
static final PathMatcher GLOBAL_BUILD_FILES_MATCHER = toPathMatcher(escapeBackslashes(
25+
String.format("**%s{%s}", File.separator, String.join(",", BuildFileType.ALL_FILENAMES))));
26+
2327
private FilePatterns() {
2428
}
2529

30+
private enum SmithyFilePatternOptions {
31+
IS_WATCHER_PATTERN,
32+
IS_PATH_MATCHER_PATTERN;
33+
34+
private static final EnumSet<SmithyFilePatternOptions> WATCHER = EnumSet.of(IS_WATCHER_PATTERN);
35+
private static final EnumSet<SmithyFilePatternOptions> PATH_MATCHER = EnumSet.of(IS_PATH_MATCHER_PATTERN);
36+
private static final EnumSet<SmithyFilePatternOptions> ALL = EnumSet.allOf(SmithyFilePatternOptions.class);
37+
}
38+
39+
private enum BuildFilePatternOptions {
40+
IS_WORKSPACE_PATTERN,
41+
IS_PATH_MATCHER_PATTERN;
42+
43+
private static final EnumSet<BuildFilePatternOptions> WORKSPACE = EnumSet.of(IS_WORKSPACE_PATTERN);
44+
private static final EnumSet<BuildFilePatternOptions> PATH_MATCHER = EnumSet.of(IS_PATH_MATCHER_PATTERN);
45+
private static final EnumSet<BuildFilePatternOptions> ALL = EnumSet.allOf(BuildFilePatternOptions.class);
46+
}
47+
2648
/**
2749
* @param project The project to get watch patterns for
2850
* @return A list of glob patterns used to watch Smithy files in the given project
2951
*/
3052
static List<String> getSmithyFileWatchPatterns(Project project) {
3153
return Stream.concat(project.sources().stream(), project.imports().stream())
32-
.map(path -> getSmithyFilePattern(path, true))
54+
.map(path -> getSmithyFilePattern(path, SmithyFilePatternOptions.WATCHER))
3355
.toList();
3456
}
3557

@@ -39,25 +61,36 @@ static List<String> getSmithyFileWatchPatterns(Project project) {
3961
*/
4062
static PathMatcher getSmithyFilesPathMatcher(Project project) {
4163
String pattern = Stream.concat(project.sources().stream(), project.imports().stream())
42-
.map(path -> getSmithyFilePattern(path, false))
64+
.map(path -> getSmithyFilePattern(path, SmithyFilePatternOptions.PATH_MATCHER))
4365
.collect(Collectors.joining(","));
4466
return toPathMatcher("{" + pattern + "}");
4567
}
4668

69+
/**
70+
* @param project The project to get a path matcher for
71+
* @return A list of path matchers that match watched Smithy files in the given project
72+
*/
73+
static List<PathMatcher> getSmithyFileWatchPathMatchers(Project project) {
74+
return Stream.concat(project.sources().stream(), project.imports().stream())
75+
.map(path -> getSmithyFilePattern(path, SmithyFilePatternOptions.ALL))
76+
.map(FilePatterns::toPathMatcher)
77+
.toList();
78+
}
79+
4780
/**
4881
* @param root The root to get the watch pattern for
4982
* @return A glob pattern used to watch build files in the given workspace
5083
*/
5184
static String getWorkspaceBuildFilesWatchPattern(Path root) {
52-
return getBuildFilesPattern(root, true);
85+
return getBuildFilesPattern(root, BuildFilePatternOptions.WORKSPACE);
5386
}
5487

5588
/**
5689
* @param root The root to get a path matcher for
5790
* @return A path matcher that can check if a file is a build file within the given workspace
5891
*/
5992
static PathMatcher getWorkspaceBuildFilesPathMatcher(Path root) {
60-
String pattern = getWorkspaceBuildFilesWatchPattern(root);
93+
String pattern = getBuildFilesPattern(root, BuildFilePatternOptions.ALL);
6194
return toPathMatcher(pattern);
6295
}
6396

@@ -66,35 +99,19 @@ static PathMatcher getWorkspaceBuildFilesPathMatcher(Path root) {
6699
* @return A path matcher that can check if a file is a build file belonging to the given project
67100
*/
68101
static PathMatcher getProjectBuildFilesPathMatcher(Project project) {
69-
String pattern = getBuildFilesPattern(project.root(), false);
102+
String pattern = getBuildFilesPattern(project.root(), BuildFilePatternOptions.PATH_MATCHER);
70103
return toPathMatcher(pattern);
71104
}
72105

73106
private static PathMatcher toPathMatcher(String globPattern) {
74107
return FileSystems.getDefault().getPathMatcher("glob:" + globPattern);
75108
}
76109

77-
// Patterns for the workspace need to match on all build files in all subdirectories,
78-
// whereas patterns for projects only look at the top level (because project locations
79-
// are defined by the presence of these build files).
80-
private static String getBuildFilesPattern(Path root, boolean isWorkspacePattern) {
81-
String rootString = root.toString();
82-
if (!rootString.endsWith(File.separator)) {
83-
rootString += File.separator;
84-
}
85-
86-
if (isWorkspacePattern) {
87-
rootString += "**" + File.separator;
88-
}
89-
90-
return escapeBackslashes(rootString + "{" + String.join(",", BuildFileType.ALL_FILENAMES) + "}");
91-
}
92-
93110
// When computing the pattern used for telling the client which files to watch, we want
94111
// to only watch .smithy/.json files. We don't need it in the PathMatcher pattern because
95112
// we only need to match files, not listen for specific changes (and it is impossible anyway
96113
// because we can't have a nested pattern).
97-
private static String getSmithyFilePattern(Path path, boolean isWatcherPattern) {
114+
private static String getSmithyFilePattern(Path path, EnumSet<SmithyFilePatternOptions> options) {
98115
String glob = path.toString();
99116
if (glob.endsWith(".smithy") || glob.endsWith(".json")) {
100117
return escapeBackslashes(glob);
@@ -105,13 +122,38 @@ private static String getSmithyFilePattern(Path path, boolean isWatcherPattern)
105122
}
106123
glob += "**";
107124

108-
if (isWatcherPattern) {
109-
glob += "/*.{smithy,json}";
125+
if (options.contains(SmithyFilePatternOptions.IS_WATCHER_PATTERN)) {
126+
// For some reason, the glob pattern matching works differently on vscode vs
127+
// PathMatcher. See https://github.com/smithy-lang/smithy-language-server/issues/191
128+
if (options.contains(SmithyFilePatternOptions.IS_PATH_MATCHER_PATTERN)) {
129+
glob += ".{smithy,json}";
130+
} else {
131+
glob += "/*.{smithy,json}";
132+
}
110133
}
111134

112135
return escapeBackslashes(glob);
113136
}
114137

138+
// Patterns for the workspace need to match on all build files in all subdirectories,
139+
// whereas patterns for projects only look at the top level (because project locations
140+
// are defined by the presence of these build files).
141+
private static String getBuildFilesPattern(Path root, EnumSet<BuildFilePatternOptions> options) {
142+
String rootString = root.toString();
143+
if (!rootString.endsWith(File.separator)) {
144+
rootString += File.separator;
145+
}
146+
147+
if (options.contains(BuildFilePatternOptions.IS_WORKSPACE_PATTERN)) {
148+
rootString += "**";
149+
if (!options.contains(BuildFilePatternOptions.IS_PATH_MATCHER_PATTERN)) {
150+
rootString += File.separator;
151+
}
152+
}
153+
154+
return escapeBackslashes(rootString + "{" + String.join(",", BuildFileType.ALL_FILENAMES) + "}");
155+
}
156+
115157
// In glob patterns, '\' is an escape character, so it needs to escaped
116158
// itself to work as a separator (i.e. for windows)
117159
private static String escapeBackslashes(String pattern) {

src/main/java/software/amazon/smithy/lsp/ServerState.java

Lines changed: 38 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,17 @@ ProjectAndFile open(String uri, String text) {
118118
if (projectAndFile != null) {
119119
projectAndFile.file().document().applyEdit(null, text);
120120
} else {
121+
// A newly created build file or smithy file may be opened before we receive the
122+
// `didChangeWatchedFiles` notification, so we either need to load an unresolved
123+
// project (build file), or load a detached project (smithy file). When we receive
124+
// `didChangeWatchedFiles` we will move the file into a regular project, if applicable.
125+
Path path = Path.of(LspAdapter.toPath(uri));
126+
if (FilePatterns.GLOBAL_BUILD_FILES_MATCHER.matches(path)) {
127+
Project project = ProjectLoader.loadUnresolved(path, text);
128+
projects.put(uri, project);
129+
return findProjectAndFile(uri);
130+
}
131+
121132
createDetachedProject(uri, text);
122133
projectAndFile = findProjectAndFile(uri); // Note: This will always be present
123134
}
@@ -129,13 +140,16 @@ void close(String uri) {
129140
managedUris.remove(uri);
130141

131142
ProjectAndFile projectAndFile = findProjectAndFile(uri);
132-
if (projectAndFile != null && projectAndFile.project().type() == Project.Type.DETACHED) {
133-
// Only cancel tasks for detached projects, since we're dropping the project
143+
if (projectAndFile != null && shouldDropOnClose(projectAndFile.project())) {
134144
lifecycleTasks.cancelTask(uri);
135145
projects.remove(uri);
136146
}
137147
}
138148

149+
private static boolean shouldDropOnClose(Project project) {
150+
return project.type() == Project.Type.DETACHED || project.type() == Project.Type.UNRESOLVED;
151+
}
152+
139153
List<Exception> tryInitProject(Path root) {
140154
LOGGER.finest("Initializing project at " + root);
141155
lifecycleTasks.cancelAllTasks();
@@ -145,9 +159,9 @@ List<Exception> tryInitProject(Path root) {
145159
Project updatedProject = ProjectLoader.load(root, this);
146160

147161
if (updatedProject.type() == Project.Type.EMPTY) {
148-
removeProjectAndResolveDetached(projectName);
162+
removeProjectAndResolve(projectName);
149163
} else {
150-
resolveDetachedProjects(projects.get(projectName), updatedProject);
164+
resolveProjects(projects.get(projectName), updatedProject);
151165
projects.put(projectName, updatedProject);
152166
}
153167

@@ -191,7 +205,7 @@ void removeWorkspace(WorkspaceFolder folder) {
191205
}
192206

193207
for (String projectName : projectsToRemove) {
194-
removeProjectAndResolveDetached(projectName);
208+
removeProjectAndResolve(projectName);
195209
}
196210
}
197211

@@ -230,14 +244,18 @@ List<Exception> applyFileEvents(List<FileEvent> events) {
230244
return errors;
231245
}
232246

233-
private void removeProjectAndResolveDetached(String projectName) {
247+
private void removeProjectAndResolve(String projectName) {
234248
Project removedProject = projects.remove(projectName);
235249
if (removedProject != null) {
236-
resolveDetachedProjects(removedProject, Project.empty(removedProject.root()));
250+
resolveProjects(removedProject, Project.empty(removedProject.root()));
237251
}
238252
}
239253

240-
private void resolveDetachedProjects(Project oldProject, Project updatedProject) {
254+
private void resolveProjects(Project oldProject, Project updatedProject) {
255+
// There may be unresolved projects that have been resolved by the updated project, so
256+
// we need to remove them here.
257+
removeDetachedOrUnresolvedProjects(updatedProject.getAllBuildFilePaths());
258+
241259
// This is a project reload, so we need to resolve any added/removed files
242260
// that need to be moved to or from detachedProjects projects.
243261
if (oldProject != null) {
@@ -246,10 +264,7 @@ private void resolveDetachedProjects(Project oldProject, Project updatedProject)
246264

247265
Set<String> addedPaths = new HashSet<>(updatedProjectSmithyPaths);
248266
addedPaths.removeAll(currentProjectSmithyPaths);
249-
for (String addedPath : addedPaths) {
250-
String addedUri = LspAdapter.toUri(addedPath);
251-
projects.remove(addedUri); // Remove any detached projects
252-
}
267+
removeDetachedOrUnresolvedProjects(addedPaths);
253268

254269
Set<String> removedPaths = new HashSet<>(currentProjectSmithyPaths);
255270
removedPaths.removeAll(updatedProjectSmithyPaths);
@@ -261,6 +276,17 @@ private void resolveDetachedProjects(Project oldProject, Project updatedProject)
261276
createDetachedProject(removedUri, removedDocument.copyText());
262277
}
263278
}
279+
} else {
280+
// This is a new project, so there may be detached projects that are resolved by
281+
// this new project.
282+
removeDetachedOrUnresolvedProjects(updatedProject.getAllSmithyFilePaths());
283+
}
284+
}
285+
286+
private void removeDetachedOrUnresolvedProjects(Set<String> filePaths) {
287+
for (String filePath : filePaths) {
288+
String uri = LspAdapter.toUri(filePath);
289+
projects.remove(uri);
264290
}
265291
}
266292

src/main/java/software/amazon/smithy/lsp/project/BuildFiles.java

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010
import java.util.Collection;
1111
import java.util.HashMap;
1212
import java.util.Iterator;
13+
import java.util.List;
1314
import java.util.Map;
15+
import java.util.Set;
1416
import software.amazon.smithy.lsp.ManagedFiles;
1517
import software.amazon.smithy.lsp.document.Document;
1618
import software.amazon.smithy.lsp.protocol.LspAdapter;
@@ -49,6 +51,10 @@ boolean isEmpty() {
4951
return buildFiles.isEmpty();
5052
}
5153

54+
Set<String> getAllPaths() {
55+
return buildFiles.keySet();
56+
}
57+
5258
static BuildFiles of(Collection<BuildFile> buildFiles) {
5359
Map<String, BuildFile> buildFileMap = new HashMap<>(buildFiles.size());
5460
for (BuildFile buildFile : buildFiles) {
@@ -57,6 +63,18 @@ static BuildFiles of(Collection<BuildFile> buildFiles) {
5763
return new BuildFiles(buildFileMap);
5864
}
5965

66+
static BuildFiles of(Path path, Document document) {
67+
for (BuildFileType type : BuildFileType.values()) {
68+
if (path.endsWith(type.filename())) {
69+
String pathString = path.toString();
70+
BuildFile buildFile = BuildFile.create(pathString, document, type);
71+
return new BuildFiles(Map.of(pathString, buildFile));
72+
}
73+
}
74+
75+
return BuildFiles.of(List.of());
76+
}
77+
6078
static BuildFiles load(Path root, ManagedFiles managedFiles) {
6179
Map<String, BuildFile> buildFiles = new HashMap<>(BuildFileType.values().length);
6280
for (BuildFileType type : BuildFileType.values()) {

src/main/java/software/amazon/smithy/lsp/project/Project.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,16 @@ public enum Type {
8686
*/
8787
DETACHED,
8888

89+
/**
90+
* A project loaded from a single build file.
91+
*
92+
* <p>This occurs when a newly created build file is opened before we
93+
* receive its `didChangeWatchedFiles` notification, which takes care
94+
* of both adding new build files to an existing project, and creating
95+
* a new project in a new root.
96+
*/
97+
UNRESOLVED,
98+
8999
/**
90100
* A project loaded with no source or build configuration files.
91101
*/
@@ -156,6 +166,10 @@ public Set<String> getAllSmithyFilePaths() {
156166
return this.smithyFiles.keySet();
157167
}
158168

169+
public Set<String> getAllBuildFilePaths() {
170+
return this.buildFiles.getAllPaths();
171+
}
172+
159173
/**
160174
* @return All the Smithy files loaded in the project.
161175
*/

src/main/java/software/amazon/smithy/lsp/project/ProjectLoader.java

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,37 @@ public static Project loadDetached(String uri, String text) {
6767
);
6868
}
6969

70+
/**
71+
* Loads an unresolved (single config file) {@link Project} with the given file.
72+
*
73+
* @param path Path of the file to load into a project
74+
* @param text Text of the file to load into a project
75+
* @return The loaded project
76+
*/
77+
public static Project loadUnresolved(Path path, String text) {
78+
Document document = Document.of(text);
79+
BuildFiles buildFiles = BuildFiles.of(path, document);
80+
81+
// An unresolved project is meant to be resolved at a later point, so we don't
82+
// even try loading its configuration from the build file.
83+
ProjectConfig config = ProjectConfig.empty();
84+
85+
// We aren't loading any smithy files in this project, so use a no-op ManagedFiles.
86+
LoadModelResult result = doLoad((fileUri) -> null, config);
87+
88+
return new Project(
89+
path,
90+
config,
91+
buildFiles,
92+
result.smithyFiles(),
93+
result.assemblerFactory(),
94+
Project.Type.UNRESOLVED,
95+
result.modelResult(),
96+
result.rebuildIndex(),
97+
List.of()
98+
);
99+
}
100+
70101
/**
71102
* Loads a {@link Project} at the given root path, using any {@code managedDocuments}
72103
* instead of loading from disk.

0 commit comments

Comments
 (0)