diff --git a/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java b/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java index 78587faa..0f05a334 100644 --- a/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java +++ b/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java @@ -16,6 +16,8 @@ package software.amazon.smithy.lsp; import static java.util.concurrent.CompletableFuture.completedFuture; +import static java.util.concurrent.CompletableFuture.supplyAsync; +import static org.eclipse.lsp4j.jsonrpc.CompletableFutures.computeAsync; import java.io.IOException; import java.util.ArrayList; @@ -79,7 +81,6 @@ import org.eclipse.lsp4j.WorkspaceFolder; import org.eclipse.lsp4j.WorkspaceFoldersOptions; import org.eclipse.lsp4j.WorkspaceServerCapabilities; -import org.eclipse.lsp4j.jsonrpc.CompletableFutures; import org.eclipse.lsp4j.jsonrpc.messages.Either; import org.eclipse.lsp4j.services.LanguageClient; import org.eclipse.lsp4j.services.LanguageClientAware; @@ -93,6 +94,7 @@ import software.amazon.smithy.lsp.ext.SelectorParams; import software.amazon.smithy.lsp.ext.ServerStatus; import software.amazon.smithy.lsp.ext.SmithyProtocolExtensions; +import software.amazon.smithy.lsp.language.BuildCompletionHandler; import software.amazon.smithy.lsp.language.CompletionHandler; import software.amazon.smithy.lsp.language.DefinitionHandler; import software.amazon.smithy.lsp.language.DocumentSymbolHandler; @@ -537,13 +539,18 @@ public CompletableFuture, CompletionList>> completio return completedFuture(Either.forLeft(Collections.emptyList())); } - if (!(projectAndFile.file() instanceof IdlFile smithyFile)) { - return completedFuture(Either.forLeft(List.of())); - } - Project project = projectAndFile.project(); - var handler = new CompletionHandler(project, smithyFile); - return CompletableFutures.computeAsync((cc) -> Either.forLeft(handler.handle(params, cc))); + return switch (projectAndFile.file()) { + case IdlFile idlFile -> { + var handler = new CompletionHandler(project, idlFile); + yield computeAsync((cc) -> Either.forLeft(handler.handle(params, cc))); + } + case BuildFile buildFile -> { + var handler = new BuildCompletionHandler(project, buildFile); + yield supplyAsync(() -> Either.forLeft(handler.handle(params))); + } + default -> completedFuture(Either.forLeft(List.of())); + }; } @Override diff --git a/src/main/java/software/amazon/smithy/lsp/language/BuildCompletionHandler.java b/src/main/java/software/amazon/smithy/lsp/language/BuildCompletionHandler.java new file mode 100644 index 00000000..1506e98c --- /dev/null +++ b/src/main/java/software/amazon/smithy/lsp/language/BuildCompletionHandler.java @@ -0,0 +1,59 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.language; + +import java.util.List; +import org.eclipse.lsp4j.CompletionItem; +import org.eclipse.lsp4j.CompletionParams; +import org.eclipse.lsp4j.Position; +import org.eclipse.lsp4j.Range; +import software.amazon.smithy.lsp.document.DocumentId; +import software.amazon.smithy.lsp.project.BuildFile; +import software.amazon.smithy.lsp.project.Project; +import software.amazon.smithy.lsp.syntax.NodeCursor; +import software.amazon.smithy.model.shapes.Shape; + +/** + * Handles completions requests for {@link BuildFile}s. + */ +public final class BuildCompletionHandler { + private final Project project; + private final BuildFile buildFile; + + public BuildCompletionHandler(Project project, BuildFile buildFile) { + this.project = project; + this.buildFile = buildFile; + } + + /** + * @param params The request params + * @return A list of possible completions + */ + public List handle(CompletionParams params) { + Position position = CompletionHandler.getTokenPosition(params); + DocumentId id = buildFile.document().copyDocumentId(position); + Range insertRange = CompletionHandler.getInsertRange(id, position); + + Shape buildFileShape = Builtins.getBuildFileShape(buildFile.type()); + + if (buildFileShape == null) { + return List.of(); + } + + NodeCursor cursor = NodeCursor.create( + buildFile.getParse().value(), + buildFile.document().indexOfPosition(position) + ); + NodeSearch.Result searchResult = NodeSearch.search(cursor, Builtins.MODEL, buildFileShape); + var candidates = CompletionCandidates.fromSearchResult(searchResult); + + var context = CompleterContext.create(id, insertRange, project) + .withExclude(searchResult.getOtherPresentKeys()); + var mapper = new SimpleCompleter.BuildFileMapper(context); + + return new SimpleCompleter(context, mapper).getCompletionItems(candidates); + } +} diff --git a/src/main/java/software/amazon/smithy/lsp/language/Builtins.java b/src/main/java/software/amazon/smithy/lsp/language/Builtins.java index cad276e3..924d83dc 100644 --- a/src/main/java/software/amazon/smithy/lsp/language/Builtins.java +++ b/src/main/java/software/amazon/smithy/lsp/language/Builtins.java @@ -8,6 +8,7 @@ import java.util.Arrays; import java.util.Map; import java.util.stream.Collectors; +import software.amazon.smithy.lsp.project.BuildFileType; import software.amazon.smithy.model.Model; import software.amazon.smithy.model.shapes.MemberShape; import software.amazon.smithy.model.shapes.Shape; @@ -35,6 +36,7 @@ final class Builtins { .addImport(Builtins.class.getResource("control.smithy")) .addImport(Builtins.class.getResource("metadata.smithy")) .addImport(Builtins.class.getResource("members.smithy")) + .addImport(Builtins.class.getResource("build.smithy")) .assemble() .unwrap(); @@ -51,6 +53,10 @@ final class Builtins { static final Shape SHAPE_MEMBER_TARGETS = MODEL.expectShape(id("ShapeMemberTargets")); + static final Shape SMITHY_BUILD_JSON = MODEL.expectShape(id("SmithyBuildJson")); + + static final Shape SMITHY_PROJECT_JSON = MODEL.expectShape(id("SmithyProjectJson")); + static final Map VALIDATOR_CONFIG_MAPPING = VALIDATORS.members().stream() .collect(Collectors.toMap( MemberShape::getMemberName, @@ -104,6 +110,14 @@ static Shape getMemberTargetForShapeType(String shapeType, String memberName) { .orElse(null); } + static Shape getBuildFileShape(BuildFileType type) { + return switch (type) { + case SMITHY_BUILD -> SMITHY_BUILD_JSON; + case SMITHY_PROJECT -> SMITHY_PROJECT_JSON; + default -> null; + }; + } + private static ShapeId id(String name) { return ShapeId.fromParts(NAMESPACE, name); } diff --git a/src/main/java/software/amazon/smithy/lsp/language/CompletionHandler.java b/src/main/java/software/amazon/smithy/lsp/language/CompletionHandler.java index 48fc881e..9e26e402 100644 --- a/src/main/java/software/amazon/smithy/lsp/language/CompletionHandler.java +++ b/src/main/java/software/amazon/smithy/lsp/language/CompletionHandler.java @@ -96,7 +96,7 @@ public List handle(CompletionParams params, CancelChecker cc) { }; } - private static Position getTokenPosition(CompletionParams params) { + static Position getTokenPosition(CompletionParams params) { Position position = params.getPosition(); CompletionContext context = params.getContext(); if (context != null @@ -107,7 +107,7 @@ private static Position getTokenPosition(CompletionParams params) { return position; } - private static Range getInsertRange(DocumentId id, Position position) { + static Range getInsertRange(DocumentId id, Position position) { if (id == null || id.idSlice().isEmpty()) { // When we receive the completion request, we're always on the // character either after what has just been typed, or we're in diff --git a/src/main/java/software/amazon/smithy/lsp/language/SimpleCompleter.java b/src/main/java/software/amazon/smithy/lsp/language/SimpleCompleter.java index 75c9c31b..dc19b712 100644 --- a/src/main/java/software/amazon/smithy/lsp/language/SimpleCompleter.java +++ b/src/main/java/software/amazon/smithy/lsp/language/SimpleCompleter.java @@ -22,10 +22,16 @@ * Maps simple {@link CompletionCandidates} to {@link CompletionItem}s. * * @param context The context for creating completions. + * @param mapper The mapper used to map candidates to completion items. + * Defaults to {@link Mapper} * * @see ShapeCompleter for what maps {@link CompletionCandidates.Shapes}. */ -record SimpleCompleter(CompleterContext context) { +record SimpleCompleter(CompleterContext context, Mapper mapper) { + SimpleCompleter(CompleterContext context) { + this(context, new Mapper(context)); + } + List getCompletionItems(CompletionCandidates candidates) { Matcher matcher; if (context.exclude().isEmpty()) { @@ -34,12 +40,10 @@ List getCompletionItems(CompletionCandidates candidates) { matcher = new ExcludingMatcher(context.matchToken(), context.exclude()); } - Mapper mapper = new Mapper(context().insertRange(), context().literalKind()); - - return getCompletionItems(candidates, matcher, mapper); + return getCompletionItems(candidates, matcher); } - private List getCompletionItems(CompletionCandidates candidates, Matcher matcher, Mapper mapper) { + private List getCompletionItems(CompletionCandidates candidates, Matcher matcher) { return switch (candidates) { case CompletionCandidates.Constant(var value) when !value.isEmpty() && matcher.testConstant(value) -> List.of(mapper.constant(value)); @@ -64,11 +68,11 @@ private List getCompletionItems(CompletionCandidates candidates, .map(mapper::elided) .toList(); - case CompletionCandidates.Custom custom -> getCompletionItems(customCandidates(custom), matcher, mapper); + case CompletionCandidates.Custom custom -> getCompletionItems(customCandidates(custom), matcher); case CompletionCandidates.And(var one, var two) -> { - List oneItems = getCompletionItems(one); - List twoItems = getCompletionItems(two); + List oneItems = getCompletionItems(one, matcher); + List twoItems = getCompletionItems(two, matcher); List completionItems = new ArrayList<>(oneItems.size() + twoItems.size()); completionItems.addAll(oneItems); completionItems.addAll(twoItems); @@ -157,12 +161,16 @@ public boolean test(String s) { /** * Maps different kinds of completion candidates to {@link CompletionItem}s. - * - * @param insertRange The range the completion text will occupy. - * @param literalKind The completion item kind that will be shown in the - * client for {@link CompletionCandidates.Literals}. */ - private record Mapper(Range insertRange, CompletionItemKind literalKind) { + static class Mapper { + private final Range insertRange; + private final CompletionItemKind literalKind; + + Mapper(CompleterContext context) { + this.insertRange = context.insertRange(); + this.literalKind = context.literalKind(); + } + CompletionItem constant(String value) { return textEditCompletion(value, CompletionItemKind.Constant); } @@ -184,11 +192,11 @@ CompletionItem elided(String memberName) { return textEditCompletion("$" + memberName, CompletionItemKind.Field); } - private CompletionItem textEditCompletion(String label, CompletionItemKind kind) { + protected CompletionItem textEditCompletion(String label, CompletionItemKind kind) { return textEditCompletion(label, kind, label); } - private CompletionItem textEditCompletion(String label, CompletionItemKind kind, String insertText) { + protected CompletionItem textEditCompletion(String label, CompletionItemKind kind, String insertText) { CompletionItem item = new CompletionItem(label); item.setKind(kind); TextEdit textEdit = new TextEdit(insertRange, insertText); @@ -196,4 +204,16 @@ private CompletionItem textEditCompletion(String label, CompletionItemKind kind, return item; } } + + static final class BuildFileMapper extends Mapper { + BuildFileMapper(CompleterContext context) { + super(context); + } + + @Override + CompletionItem member(Map.Entry entry) { + String value = "\"" + entry.getKey() + "\": " + entry.getValue().value(); + return textEditCompletion(entry.getKey(), CompletionItemKind.Field, value); + } + } } diff --git a/src/main/resources/software/amazon/smithy/lsp/language/build.smithy b/src/main/resources/software/amazon/smithy/lsp/language/build.smithy new file mode 100644 index 00000000..75f4b85c --- /dev/null +++ b/src/main/resources/software/amazon/smithy/lsp/language/build.smithy @@ -0,0 +1,90 @@ +$version: "2.0" + +namespace smithy.lang.server + +structure SmithyProjectJson { + sources: Strings + imports: Strings + outputDirectory: String + dependencies: ProjectDependencies +} + +list ProjectDependencies { + member: ProjectDependency +} + +structure ProjectDependency { + name: String + + @required + path: String +} + +structure SmithyBuildJson { + @required + version: SmithyBuildVersion + + outputDirectory: String + sources: Strings + imports: Strings + projections: Projections + plugins: Plugins + ignoreMissingPlugins: Boolean + maven: Maven +} + +@default("1") +string SmithyBuildVersion + +map Projections { + key: String + value: Projection +} + +structure Projection { + abstract: Boolean + imports: Strings + transforms: Transforms + plugins: Plugins +} + +map Plugins { + key: String + value: Document +} + +list Transforms { + member: Transform +} + +structure Transform { + @required + name: String + + args: TransformArgs +} + +structure TransformArgs { +} + +structure Maven { + dependencies: Strings + repositories: MavenRepositories +} + +list MavenRepositories { + member: MavenRepository +} + +structure MavenRepository { + @required + url: String + + httpCredentials: String + proxyHost: String + proxyCredentials: String +} + +list Strings { + member: String +} diff --git a/src/test/java/software/amazon/smithy/lsp/LspMatchers.java b/src/test/java/software/amazon/smithy/lsp/LspMatchers.java index dbe118d1..e9452086 100644 --- a/src/test/java/software/amazon/smithy/lsp/LspMatchers.java +++ b/src/test/java/software/amazon/smithy/lsp/LspMatchers.java @@ -36,6 +36,16 @@ public void describeMismatchSafely(CompletionItem item, Description description) }; } + public static Matcher hasLabelAndEditText(String label, String editText) { + return new CustomTypeSafeMatcher<>("label " + label + " editText " + editText) { + @Override + protected boolean matchesSafely(CompletionItem item) { + return label.equals(item.getLabel()) + && editText.trim().equals(item.getTextEdit().getLeft().getNewText().trim()); + } + }; + } + public static Matcher makesEditedDocument(Document document, String expected) { return new CustomTypeSafeMatcher<>("makes an edited document " + expected) { @Override diff --git a/src/test/java/software/amazon/smithy/lsp/language/BuildCompletionHandlerTest.java b/src/test/java/software/amazon/smithy/lsp/language/BuildCompletionHandlerTest.java new file mode 100644 index 00000000..499e7129 --- /dev/null +++ b/src/test/java/software/amazon/smithy/lsp/language/BuildCompletionHandlerTest.java @@ -0,0 +1,308 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.lsp.language; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static software.amazon.smithy.lsp.LspMatchers.hasLabelAndEditText; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import org.eclipse.lsp4j.CompletionItem; +import org.eclipse.lsp4j.CompletionParams; +import org.eclipse.lsp4j.Position; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.lsp.RequestBuilders; +import software.amazon.smithy.lsp.TextWithPositions; +import software.amazon.smithy.lsp.project.BuildFile; +import software.amazon.smithy.lsp.project.BuildFileType; +import software.amazon.smithy.lsp.project.Project; +import software.amazon.smithy.lsp.project.ProjectTest; +import software.amazon.smithy.lsp.protocol.LspAdapter; + +public class BuildCompletionHandlerTest { + @Test + public void completesSmithyBuildJsonTopLevel() { + var text = TextWithPositions.from(""" + { + % + } + """); + var items = getCompItems(text, BuildFileType.SMITHY_BUILD); + + assertThat(items, containsInAnyOrder( + hasLabelAndEditText("version", """ + "version": "1" + """), + hasLabelAndEditText("outputDirectory", """ + "outputDirectory": "" + """), + hasLabelAndEditText("sources", """ + "sources": [] + """), + hasLabelAndEditText("imports", """ + "imports": [] + """), + hasLabelAndEditText("projections", """ + "projections": {} + """), + hasLabelAndEditText("plugins", """ + "plugins": {} + """), + hasLabelAndEditText("ignoreMissingPlugins", """ + "ignoreMissingPlugins": + """), + hasLabelAndEditText("maven", """ + "maven": {} + """) + )); + } + + @Test + public void completesSmithyBuildJsonProjectionMembers() { + var text = TextWithPositions.from(""" + { + "projections": { + "foo": { + % + } + } + } + """); + var items = getCompItems(text, BuildFileType.SMITHY_BUILD); + + assertThat(items, containsInAnyOrder( + hasLabelAndEditText("abstract", """ + "abstract": + """), + hasLabelAndEditText("imports", """ + "imports": [] + """), + hasLabelAndEditText("transforms", """ + "transforms": [] + """), + hasLabelAndEditText("plugins", """ + "plugins": {} + """) + )); + } + + @Test + public void completesSmithyBuildJsonTransformMembers() { + var text = TextWithPositions.from(""" + { + "projections": { + "foo": { + "transforms": [ + { + % + } + ] + } + } + } + """); + var items = getCompItems(text, BuildFileType.SMITHY_BUILD); + + assertThat(items, containsInAnyOrder( + hasLabelAndEditText("name", """ + "name": "" + """), + hasLabelAndEditText("args", """ + "args": {} + """) + )); + } + + @Test + public void completesSmithyBuildJsonMavenMembers() { + var text = TextWithPositions.from(""" + { + "maven": { + % + } + } + """); + var items = getCompItems(text, BuildFileType.SMITHY_BUILD); + + assertThat(items, containsInAnyOrder( + hasLabelAndEditText("dependencies", """ + "dependencies": [] + """), + hasLabelAndEditText("repositories", """ + "repositories": [] + """) + )); + } + + @Test + public void completesSmithyBuildJsonMavenRepoMembers() { + var text = TextWithPositions.from(""" + { + "maven": { + "repositories": [ + { + % + } + ] + } + } + """); + var items = getCompItems(text, BuildFileType.SMITHY_BUILD); + + assertThat(items, containsInAnyOrder( + hasLabelAndEditText("url", """ + "url": "" + """), + hasLabelAndEditText("httpCredentials", """ + "httpCredentials": "" + """), + hasLabelAndEditText("proxyHost", """ + "proxyHost": "" + """), + hasLabelAndEditText("proxyCredentials", """ + "proxyCredentials": "" + """) + )); + } + + @Test + public void completesSmithyProjectJsonTopLevel() { + var text = TextWithPositions.from(""" + { + % + } + """); + var items = getCompItems(text, BuildFileType.SMITHY_PROJECT); + + assertThat(items, containsInAnyOrder( + hasLabelAndEditText("sources", """ + "sources": [] + """), + hasLabelAndEditText("imports", """ + "imports": [] + """), + hasLabelAndEditText("outputDirectory", """ + "outputDirectory": "" + """), + hasLabelAndEditText("dependencies", """ + "dependencies": [] + """) + )); + } + + @Test + public void completesSmithyProjectJsonDependencyMembers() { + var text = TextWithPositions.from(""" + { + "dependencies": [ + { + % + } + ] + } + """); + var items = getCompItems(text, BuildFileType.SMITHY_PROJECT); + + assertThat(items, containsInAnyOrder( + hasLabelAndEditText("name", """ + "name": "" + """), + hasLabelAndEditText("path", """ + "path": "" + """) + )); + } + + @Test + public void matchesStringKeys() { + var text = TextWithPositions.from(""" + { + "v%" + } + """); + var items = getCompItems(text, BuildFileType.SMITHY_BUILD); + + assertThat(items, containsInAnyOrder( + hasLabelAndEditText("version", """ + "version": "1" + """) + )); + } + + @Test + public void matchesNonStringKeys() { + var text = TextWithPositions.from(""" + { + v% + } + """); + var items = getCompItems(text, BuildFileType.SMITHY_BUILD); + + assertThat(items, containsInAnyOrder( + hasLabelAndEditText("version", """ + "version": "1" + """) + )); + } + + @Test + public void completesKeyValues() { + var text = TextWithPositions.from(""" + { + "version": %, + "projections": { + "a": { + "abstract": % + }, + "b": { + "imports": % + }, + "c": { + "plugins": % + } + } + } + """); + var items = getCompItems(text, BuildFileType.SMITHY_BUILD); + + assertThat(items, containsInAnyOrder( + hasLabelAndEditText("\"1\"", """ + "1" + """), + hasLabelAndEditText("false", "false"), + hasLabelAndEditText("true", "true"), + hasLabelAndEditText("[]", "[]"), + hasLabelAndEditText("{}", "{}") + )); + } + + private static List getCompItems(TextWithPositions twp, BuildFileType type) { + try { + Path root = Files.createTempDirectory("test"); + Path path = root.resolve(type.filename()); + Files.writeString(path, twp.text()); + Project project = ProjectTest.load(root); + String uri = LspAdapter.toUri(path.toString()); + BuildFile buildFile = (BuildFile) project.getProjectFile(uri); + List completionItems = new ArrayList<>(); + BuildCompletionHandler handler = new BuildCompletionHandler(project, buildFile); + for (Position position : twp.positions()) { + CompletionParams params = RequestBuilders.positionRequest() + .uri(uri) + .position(position) + .buildCompletion(); + completionItems.addAll(handler.handle(params)); + } + return completionItems; + } catch (IOException e) { + throw new RuntimeException(e); + } + } +}