Skip to content

Commit ca6c2d0

Browse files
authored
Build file diagnostics (#188)
This commit makes the language server send diagnostics back to build files, i.e. smithy-build.json. Previously, any issues in build files would result in the project failing to load, and those errors would be reported to the client's window. With this change, issues are recomputed on change, and sent back as diagnostics so you get squigglies. Much better. To accomplish this, a number of changes were needed: 1. Reparse build files on change. Previously, we just updated the document. 2. Have a way to run some sort of validation on build files that can tolerate errors. This is split into two parts: 1. A regular validation stage that takes the parsed build file and tries to map it to its specific java representation, collecting any errors that occur. For example, smithy-build.json is turned into SmithyBuildConfig. 2. A resolution stage that takes the java representation and tries to resolve maven dependencies, and recursively find all model paths from sources and imports. 3. Keep track of events emitted from this validation so they can be sent back to the client. 2 is the most complicated part. SmithyBuildConfig does some extra work under the hood when it is deserialized from a Node, like environment variable replacement. I wanted to make sure there wasn't any drift between the language server and other Smithy tools, so I kept using SmithyBuildConfig::fromNode, but now any exception thrown from this will be mapped to a validation event. Each of the other build files work the same way. I also kept the same merging logic for aggregating config from multiple build files. Next is the resolution part. Maven resolution can fail in multiple ways. We have to try to map any exceptions back to a source location, because we don't have access to the original source locations. For finding source/import files, I wanted to be able to report when files aren't found (this also helps to make sure assembling the model doesn't fail due to files not being found), so we have to do the same thing (that is, map back to a source location). Resolution in general is expensive, as it could be hitting maven central, but doing this mapping could also be expensive, so we don't perform the resolution step when build files change - only when a project is actually loaded. We will have to see how this validation feels, and make improvements where necessary. Additional changes: - Report Smithy's json parse errors - Added a 'use-smithy-build' diagnostic to 'legacy' build files - Fix json node parsing to properly handle commas in the IDL vs actual json
1 parent f2e3af7 commit ca6c2d0

35 files changed

+1939
-1148
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ project/project
1313
.gradle
1414

1515
# Ignore Gradle build output directory
16-
build
16+
# Note: Only ignore the top-level build dir, tests use dirs named 'build' which we don't want to ignore
17+
/build
1718

1819
bin
1920

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@
1212
import java.util.List;
1313
import java.util.stream.Collectors;
1414
import java.util.stream.Stream;
15+
import software.amazon.smithy.lsp.project.BuildFileType;
1516
import software.amazon.smithy.lsp.project.Project;
16-
import software.amazon.smithy.lsp.project.ProjectConfigLoader;
1717

1818
/**
1919
* Utility methods for computing glob patterns that match against Smithy files
@@ -87,7 +87,7 @@ private static String getBuildFilesPattern(Path root, boolean isWorkspacePattern
8787
rootString += "**" + File.separator;
8888
}
8989

90-
return escapeBackslashes(rootString + "{" + String.join(",", ProjectConfigLoader.PROJECT_BUILD_FILES) + "}");
90+
return escapeBackslashes(rootString + "{" + String.join(",", BuildFileType.ALL_FILENAMES) + "}");
9191
}
9292

9393
// When computing the pattern used for telling the client which files to watch, we want

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,14 @@
1717
import java.util.ArrayList;
1818
import java.util.EnumSet;
1919
import java.util.List;
20-
import software.amazon.smithy.lsp.project.ProjectConfigLoader;
20+
import software.amazon.smithy.lsp.project.BuildFileType;
2121

2222
/**
2323
* Finds Project roots based on the location of smithy-build.json and .smithy-project.json.
2424
*/
2525
final class ProjectRootVisitor extends SimpleFileVisitor<Path> {
2626
private static final PathMatcher PROJECT_ROOT_MATCHER = FileSystems.getDefault().getPathMatcher(
27-
"glob:{" + ProjectConfigLoader.SMITHY_BUILD + "," + ProjectConfigLoader.SMITHY_PROJECT + "}");
27+
"glob:{" + BuildFileType.SMITHY_BUILD.filename() + "," + BuildFileType.SMITHY_PROJECT.filename() + "}");
2828
private static final int MAX_VISIT_DEPTH = 10;
2929

3030
private final List<Path> roots = new ArrayList<>();

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

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@
2626
import software.amazon.smithy.lsp.project.ProjectFile;
2727
import software.amazon.smithy.lsp.project.ProjectLoader;
2828
import software.amazon.smithy.lsp.protocol.LspAdapter;
29-
import software.amazon.smithy.lsp.util.Result;
3029

3130
/**
3231
* Keeps track of the state of the server.
@@ -143,10 +142,9 @@ List<Exception> tryInitProject(Path root) {
143142
LOGGER.finest("Initializing project at " + root);
144143
lifecycleTasks.cancelAllTasks();
145144

146-
Result<Project, List<Exception>> loadResult = ProjectLoader.load(root, this);
147145
String projectName = root.toString();
148-
if (loadResult.isOk()) {
149-
Project updatedProject = loadResult.unwrap();
146+
try {
147+
Project updatedProject = ProjectLoader.load(root, this);
150148

151149
if (updatedProject.type() == Project.Type.EMPTY) {
152150
removeProjectAndResolveDetached(projectName);
@@ -157,17 +155,15 @@ List<Exception> tryInitProject(Path root) {
157155

158156
LOGGER.finest("Initialized project at " + root);
159157
return List.of();
160-
}
158+
} catch (Exception e) {
159+
LOGGER.severe("Failed to load project at " + root);
161160

162-
LOGGER.severe("Init project failed");
161+
// If we overwrite an existing project with an empty one, we lose track of the state of tracked
162+
// files. Instead, we will just keep the original project before the reload failure.
163+
projects.computeIfAbsent(projectName, ignored -> Project.empty(root));
163164

164-
// TODO: Maybe we just start with this anyways by default, and then add to it
165-
// if we find a smithy-build.json, etc.
166-
// If we overwrite an existing project with an empty one, we lose track of the state of tracked
167-
// files. Instead, we will just keep the original project before the reload failure.
168-
projects.computeIfAbsent(projectName, ignored -> Project.empty(root));
169-
170-
return loadResult.unwrapErr();
165+
return List.of(e);
166+
}
171167
}
172168

173169
void loadWorkspace(WorkspaceFolder workspaceFolder) {

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

Lines changed: 24 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -447,22 +447,31 @@ public void didChange(DidChangeTextDocumentParams params) {
447447
}
448448
}
449449

450-
// Don't reload or update the project on build file changes, only on save
451-
if (!(projectAndFile.file() instanceof SmithyFile smithyFile)) {
452-
return;
453-
}
450+
projectAndFile.file().reparse();
454451

455-
smithyFile.reparse();
456-
if (!this.serverOptions.getOnlyReloadOnSave()) {
457-
Project project = projectAndFile.project();
452+
Project project = projectAndFile.project();
453+
switch (projectAndFile.file()) {
454+
case SmithyFile ignored -> {
455+
if (this.serverOptions.getOnlyReloadOnSave()) {
456+
return;
457+
}
458+
459+
// TODO: A consequence of this is that any existing validation events are cleared, which
460+
// is kinda annoying.
461+
// Report any parse/shape/trait loading errors
462+
CompletableFuture<Void> future = CompletableFuture
463+
.runAsync(() -> project.updateModelWithoutValidating(uri))
464+
.thenRunAsync(() -> sendFileDiagnostics(projectAndFile));
465+
466+
state.lifecycleTasks().putTask(uri, future);
467+
}
468+
case BuildFile ignored -> {
469+
CompletableFuture<Void> future = CompletableFuture
470+
.runAsync(project::validateConfig)
471+
.thenRunAsync(() -> sendFileDiagnostics(projectAndFile));
458472

459-
// TODO: A consequence of this is that any existing validation events are cleared, which
460-
// is kinda annoying.
461-
// Report any parse/shape/trait loading errors
462-
CompletableFuture<Void> future = CompletableFuture
463-
.runAsync(() -> project.updateModelWithoutValidating(uri))
464-
.thenComposeAsync(unused -> sendFileDiagnostics(projectAndFile));
465-
state.lifecycleTasks().putTask(uri, future);
473+
state.lifecycleTasks().putTask(uri, future);
474+
}
466475
}
467476
}
468477

@@ -512,7 +521,7 @@ public void didSave(DidSaveTextDocumentParams params) {
512521
} else {
513522
CompletableFuture<Void> future = CompletableFuture
514523
.runAsync(() -> project.updateAndValidateModel(uri))
515-
.thenCompose(unused -> sendFileDiagnostics(projectAndFile));
524+
.thenRunAsync(() -> sendFileDiagnostics(projectAndFile));
516525
state.lifecycleTasks().putTask(uri, future);
517526
}
518527
}

src/main/java/software/amazon/smithy/lsp/diagnostics/SmithyDiagnostics.java

Lines changed: 113 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import org.eclipse.lsp4j.Position;
1616
import org.eclipse.lsp4j.Range;
1717
import software.amazon.smithy.lsp.document.DocumentParser;
18+
import software.amazon.smithy.lsp.project.BuildFile;
1819
import software.amazon.smithy.lsp.project.IdlFile;
1920
import software.amazon.smithy.lsp.project.Project;
2021
import software.amazon.smithy.lsp.project.ProjectAndFile;
@@ -32,9 +33,12 @@ public final class SmithyDiagnostics {
3233
public static final String UPDATE_VERSION = "migrating-idl-1-to-2";
3334
public static final String DEFINE_VERSION = "define-idl-version";
3435
public static final String DETACHED_FILE = "detached-file";
36+
public static final String USE_SMITHY_BUILD = "use-smithy-build";
3537

3638
private static final DiagnosticCodeDescription UPDATE_VERSION_DESCRIPTION =
3739
new DiagnosticCodeDescription("https://smithy.io/2.0/guides/migrating-idl-1-to-2.html");
40+
private static final DiagnosticCodeDescription USE_SMITHY_BUILD_DESCRIPTION =
41+
new DiagnosticCodeDescription("https://smithy.io/2.0/guides/smithy-build-json.html#using-smithy-build-json");
3842

3943
private SmithyDiagnostics() {
4044
}
@@ -51,82 +55,140 @@ public static List<Diagnostic> getFileDiagnostics(ProjectAndFile projectAndFile,
5155
return List.of();
5256
}
5357

54-
if (!(projectAndFile.file() instanceof SmithyFile smithyFile)) {
55-
return List.of();
56-
}
58+
Diagnose diagnose = switch (projectAndFile.file()) {
59+
case SmithyFile smithyFile -> new DiagnoseSmithy(smithyFile, projectAndFile.project());
60+
case BuildFile buildFile -> new DiagnoseBuild(buildFile, projectAndFile.project());
61+
};
5762

58-
Project project = projectAndFile.project();
5963
String path = projectAndFile.file().path();
64+
EventToDiagnostic eventToDiagnostic = diagnose.getEventToDiagnostic();
6065

61-
EventToDiagnostic eventToDiagnostic = eventToDiagnostic(smithyFile);
62-
63-
List<Diagnostic> diagnostics = project.modelResult().getValidationEvents().stream()
66+
List<Diagnostic> diagnostics = diagnose.getValidationEvents().stream()
6467
.filter(event -> event.getSeverity().compareTo(minimumSeverity) >= 0
6568
&& event.getSourceLocation().getFilename().equals(path))
6669
.map(eventToDiagnostic::toDiagnostic)
6770
.collect(Collectors.toCollection(ArrayList::new));
6871

69-
Diagnostic versionDiagnostic = versionDiagnostic(smithyFile);
70-
if (versionDiagnostic != null) {
71-
diagnostics.add(versionDiagnostic);
72-
}
73-
74-
if (projectAndFile.project().type() == Project.Type.DETACHED) {
75-
diagnostics.add(detachedDiagnostic(smithyFile));
76-
}
72+
diagnose.addExtraDiagnostics(diagnostics);
7773

7874
return diagnostics;
7975
}
8076

81-
private static Diagnostic versionDiagnostic(SmithyFile smithyFile) {
82-
if (!(smithyFile instanceof IdlFile idlFile)) {
83-
return null;
77+
private sealed interface Diagnose {
78+
List<ValidationEvent> getValidationEvents();
79+
80+
EventToDiagnostic getEventToDiagnostic();
81+
82+
void addExtraDiagnostics(List<Diagnostic> diagnostics);
83+
}
84+
85+
private record DiagnoseSmithy(SmithyFile smithyFile, Project project) implements Diagnose {
86+
@Override
87+
public List<ValidationEvent> getValidationEvents() {
88+
return project.modelResult().getValidationEvents();
8489
}
8590

86-
Syntax.IdlParseResult syntaxInfo = idlFile.getParse();
87-
if (syntaxInfo.version().version().startsWith("2")) {
88-
return null;
89-
} else if (!LspAdapter.isEmpty(syntaxInfo.version().range())) {
90-
var diagnostic = createDiagnostic(
91-
syntaxInfo.version().range(), "You can upgrade to idl version 2.", UPDATE_VERSION);
92-
diagnostic.setCodeDescription(UPDATE_VERSION_DESCRIPTION);
93-
return diagnostic;
94-
} else {
95-
int end = smithyFile.document().lineEnd(0);
96-
Range range = LspAdapter.lineSpan(0, 0, end);
97-
return createDiagnostic(range, "You should define a version for your Smithy file", DEFINE_VERSION);
91+
@Override
92+
public EventToDiagnostic getEventToDiagnostic() {
93+
if (!(smithyFile instanceof IdlFile idlFile)) {
94+
return new Simple();
95+
}
96+
97+
var idlParse = idlFile.getParse();
98+
var view = StatementView.createAtStart(idlParse).orElse(null);
99+
if (view == null) {
100+
return new Simple();
101+
} else {
102+
var documentParser = DocumentParser.forStatements(
103+
smithyFile.document(), view.parseResult().statements());
104+
return new Idl(view, documentParser);
105+
}
98106
}
99-
}
100107

101-
private static Diagnostic detachedDiagnostic(SmithyFile smithyFile) {
102-
Range range;
103-
if (smithyFile.document() == null) {
104-
range = LspAdapter.origin();
105-
} else {
106-
int end = smithyFile.document().lineEnd(0);
107-
range = LspAdapter.lineSpan(0, 0, end);
108+
@Override
109+
public void addExtraDiagnostics(List<Diagnostic> diagnostics) {
110+
Diagnostic versionDiagnostic = versionDiagnostic(smithyFile);
111+
if (versionDiagnostic != null) {
112+
diagnostics.add(versionDiagnostic);
113+
}
114+
115+
if (project.type() == Project.Type.DETACHED) {
116+
diagnostics.add(detachedDiagnostic(smithyFile));
117+
}
108118
}
109119

110-
return createDiagnostic(range, "This file isn't attached to a project", DETACHED_FILE);
111-
}
112120

113-
private static Diagnostic createDiagnostic(Range range, String title, String code) {
114-
return new Diagnostic(range, title, DiagnosticSeverity.Warning, "smithy-language-server", code);
121+
private static Diagnostic versionDiagnostic(SmithyFile smithyFile) {
122+
if (!(smithyFile instanceof IdlFile idlFile)) {
123+
return null;
124+
}
125+
126+
Syntax.IdlParseResult syntaxInfo = idlFile.getParse();
127+
if (syntaxInfo.version().version().startsWith("2")) {
128+
return null;
129+
} else if (!LspAdapter.isEmpty(syntaxInfo.version().range())) {
130+
var diagnostic = createDiagnostic(
131+
syntaxInfo.version().range(), "You can upgrade to idl version 2.", UPDATE_VERSION);
132+
diagnostic.setCodeDescription(UPDATE_VERSION_DESCRIPTION);
133+
return diagnostic;
134+
} else {
135+
int end = smithyFile.document().lineEnd(0);
136+
Range range = LspAdapter.lineSpan(0, 0, end);
137+
return createDiagnostic(range, "You should define a version for your Smithy file", DEFINE_VERSION);
138+
}
139+
}
140+
141+
private static Diagnostic detachedDiagnostic(SmithyFile smithyFile) {
142+
Range range;
143+
if (smithyFile.document() == null) {
144+
range = LspAdapter.origin();
145+
} else {
146+
int end = smithyFile.document().lineEnd(0);
147+
range = LspAdapter.lineSpan(0, 0, end);
148+
}
149+
150+
return createDiagnostic(range, "This file isn't attached to a project", DETACHED_FILE);
151+
}
115152
}
116153

117-
private static EventToDiagnostic eventToDiagnostic(SmithyFile smithyFile) {
118-
if (!(smithyFile instanceof IdlFile idlFile)) {
119-
return new Simple();
154+
private record DiagnoseBuild(BuildFile buildFile, Project project) implements Diagnose {
155+
@Override
156+
public List<ValidationEvent> getValidationEvents() {
157+
return project().configEvents();
120158
}
121159

122-
var idlParse = idlFile.getParse();
123-
var view = StatementView.createAtStart(idlParse).orElse(null);
124-
if (view == null) {
160+
@Override
161+
public EventToDiagnostic getEventToDiagnostic() {
125162
return new Simple();
126-
} else {
127-
var documentParser = DocumentParser.forStatements(smithyFile.document(), view.parseResult().statements());
128-
return new Idl(view, documentParser);
129163
}
164+
165+
@Override
166+
public void addExtraDiagnostics(List<Diagnostic> diagnostics) {
167+
switch (buildFile.type()) {
168+
case SMITHY_BUILD_EXT_0, SMITHY_BUILD_EXT_1 -> diagnostics.add(useSmithyBuild());
169+
default -> {
170+
}
171+
}
172+
}
173+
174+
private Diagnostic useSmithyBuild() {
175+
Range range = LspAdapter.origin();
176+
Diagnostic diagnostic = createDiagnostic(
177+
range,
178+
String.format("""
179+
You should use smithy-build.json as your build configuration file for Smithy.
180+
The %s file is not supported by Smithy, and support from the language server
181+
will be removed in a later version.
182+
""", buildFile.type().filename()),
183+
USE_SMITHY_BUILD
184+
);
185+
diagnostic.setCodeDescription(USE_SMITHY_BUILD_DESCRIPTION);
186+
return diagnostic;
187+
}
188+
}
189+
190+
private static Diagnostic createDiagnostic(Range range, String title, String code) {
191+
return new Diagnostic(range, title, DiagnosticSeverity.Warning, "smithy-language-server", code);
130192
}
131193

132194
private sealed interface EventToDiagnostic {

0 commit comments

Comments
 (0)