From 03ed01744c3457035a4a8ec052b34b8e650f0926 Mon Sep 17 00:00:00 2001 From: Miles Ziemer Date: Fri, 1 Sep 2023 09:53:14 -0400 Subject: [PATCH] Store model text in memory This is primarily a refactor of SmithyProject to store raw model text in memory. Previously, we only stored file objects for the model, and gave those to the model assembler. There are two issues with this approach: 1. While a file is being editted, the source of truth for the file contents should be what the client sends back to the server in the `didChange` event, not whats actually in the file. To get around this, we've been using a temporary file that stores the contents of the file being editted, and giving that to the model assembler. When publishing diagnostics however, we needed to re-map the file location from the temporary file to the actual file on disk. 2. The language server needs literal text to provide some of its features, meaning we need to read in files frequently. These changes update SmithyProject to store a map of URI -> String for each of the model files in the loaded model by walking the shapes and collecting all unique file locations. SmithyTDS is updated to use that when it needs to have literal text of the model for formatting, version diagnostics, and other features. Various other updates were also made: - Added a list of errors to SmithyProject that store any errors that occur while loading the project. Previously, any "load" methods would return `Either`, which isn't very ergonomic to use. These errors represent failures in the project loading, not validation errors from loading the model. - SmithyProject's loading methods are reworked to provide 2 static methods: one for loading the project in a directory, one for loading from specific smithy-build config, and 2 instance methods: one for regular reloading, one for reloading with specific file changes. Previously, we had to manage the loading of the temporary file (or its removal). This also moves all the loading logic into SmithyProject. - String-literal uris are replaced with URI class since LSP guarantees uris will be valid (see https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#uri). - SmithyInterface has new method to load from text-literal sources. - Completions updated to handle invalid model result, so callers don't have to. This allows removing `getCompletions` from SmithyProject. - Various access modifiers adjusted to be more strict, tightening the API to make it easier for us to change implementations. The following test updates were made: - Added multiple test cases for different project loading & reloading situations. - Some tests needed to use URI class instead of uri strings. --- .../amazon/smithy/lsp/SmithyInterface.java | 43 +- .../smithy/lsp/SmithyLanguageServer.java | 10 +- .../smithy/lsp/SmithyTextDocumentService.java | 392 ++++-------------- .../software/amazon/smithy/lsp/Utils.java | 14 + .../lsp/diagnostics/VersionDiagnostics.java | 5 +- .../amazon/smithy/lsp/ext/Completions.java | 21 +- .../amazon/smithy/lsp/ext/SmithyProject.java | 319 ++++++++++---- .../smithy/lsp/DefinitionProviderTest.java | 4 +- .../amazon/smithy/lsp/HoverProviderTest.java | 4 +- .../lsp/SmithyTextDocumentServiceTest.java | 55 +-- .../smithy/lsp/ext/CompletionsTest.java | 14 +- .../amazon/smithy/lsp/ext/Harness.java | 22 +- .../smithy/lsp/ext/SmithyProjectTest.java | 206 ++++++++- 13 files changed, 624 insertions(+), 485 deletions(-) diff --git a/src/main/java/software/amazon/smithy/lsp/SmithyInterface.java b/src/main/java/software/amazon/smithy/lsp/SmithyInterface.java index bafe93dc..71e22cb8 100644 --- a/src/main/java/software/amazon/smithy/lsp/SmithyInterface.java +++ b/src/main/java/software/amazon/smithy/lsp/SmithyInterface.java @@ -20,6 +20,7 @@ import java.net.URL; import java.net.URLClassLoader; import java.util.Collection; +import java.util.Map; import org.eclipse.lsp4j.jsonrpc.messages.Either; import software.amazon.smithy.lsp.ext.LspLog; import software.amazon.smithy.model.Model; @@ -43,13 +44,7 @@ private SmithyInterface() { public static Either> readModel(Collection files, Collection externalJars) { try { - URL[] urls = externalJars.stream().map(SmithyInterface::fileToUrl).toArray(URL[]::new); - URLClassLoader urlClassLoader = new URLClassLoader(urls); - ModelAssembler assembler = Model.assembler(urlClassLoader) - .discoverModels(urlClassLoader) - // We don't want the model to be broken when there are unknown traits, - // because that will essentially disable language server features. - .putProperty(ModelAssembler.ALLOW_UNKNOWN_TRAITS, true); + ModelAssembler assembler = getModelAssembler(externalJars); for (File file : files) { assembler.addImport(file.getAbsolutePath()); @@ -62,6 +57,40 @@ public static Either> readModel(Collection> readModel( + Map sources, + Collection externalJars + ) { + try { + ModelAssembler assembler = getModelAssembler(externalJars); + for (String uri : sources.keySet()) { + assembler.addUnparsedModel(uri, sources.get(uri)); + } + return Either.forRight(assembler.assemble()); + } catch (Exception e) { + LspLog.println(e); + return Either.forLeft(e); + } + } + + private static ModelAssembler getModelAssembler(Collection externalJars) { + URL[] urls = externalJars.stream().map(SmithyInterface::fileToUrl).toArray(URL[]::new); + URLClassLoader urlClassLoader = new URLClassLoader(urls); + return Model.assembler(urlClassLoader) + .discoverModels(urlClassLoader) + // We don't want the model to be broken when there are unknown traits, + // because that will essentially disable language server features. + .putProperty(ModelAssembler.ALLOW_UNKNOWN_TRAITS, true); + } + private static URL fileToUrl(File file) { try { return file.toURI().toURL(); diff --git a/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java b/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java index 6afaa147..55444321 100644 --- a/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java +++ b/src/main/java/software/amazon/smithy/lsp/SmithyLanguageServer.java @@ -124,15 +124,7 @@ public WorkspaceService getWorkspaceService() { @Override public TextDocumentService getTextDocumentService() { - File temp = null; - try { - temp = Files.createTempDirectory("smithy-lsp").toFile(); - LspLog.println("Created a temporary folder for file contents " + temp); - temp.deleteOnExit(); - } catch (IOException e) { - LspLog.println("Failed to create a temporary folder " + e); - } - SmithyTextDocumentService local = new SmithyTextDocumentService(this.client, temp); + SmithyTextDocumentService local = new SmithyTextDocumentService(this.client); tds = Optional.of(local); return local; } diff --git a/src/main/java/software/amazon/smithy/lsp/SmithyTextDocumentService.java b/src/main/java/software/amazon/smithy/lsp/SmithyTextDocumentService.java index c3baab5e..f9a9687a 100644 --- a/src/main/java/software/amazon/smithy/lsp/SmithyTextDocumentService.java +++ b/src/main/java/software/amazon/smithy/lsp/SmithyTextDocumentService.java @@ -15,16 +15,11 @@ package software.amazon.smithy.lsp; -import com.google.common.hash.HashFunction; -import com.google.common.hash.Hashing; import java.io.File; -import java.io.IOException; -import java.io.UnsupportedEncodingException; import java.net.URI; import java.net.URISyntaxException; import java.net.URLDecoder; import java.nio.charset.StandardCharsets; -import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; @@ -35,10 +30,8 @@ import java.util.Map; import java.util.Optional; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Collectors; import java.util.stream.Stream; -import javax.annotation.Nullable; import org.eclipse.lsp4j.CodeAction; import org.eclipse.lsp4j.CodeActionParams; import org.eclipse.lsp4j.Command; @@ -66,30 +59,22 @@ import org.eclipse.lsp4j.Range; import org.eclipse.lsp4j.SymbolInformation; import org.eclipse.lsp4j.SymbolKind; -import org.eclipse.lsp4j.TextDocumentIdentifier; -import org.eclipse.lsp4j.TextDocumentItem; import org.eclipse.lsp4j.TextEdit; import org.eclipse.lsp4j.jsonrpc.messages.Either; import org.eclipse.lsp4j.services.LanguageClient; import org.eclipse.lsp4j.services.TextDocumentService; import smithyfmt.Formatter; import smithyfmt.Result; -import software.amazon.smithy.cli.dependencies.DependencyResolver; -import software.amazon.smithy.cli.dependencies.FileCacheResolver; -import software.amazon.smithy.cli.dependencies.MavenDependencyResolver; import software.amazon.smithy.lsp.codeactions.SmithyCodeActions; import software.amazon.smithy.lsp.diagnostics.VersionDiagnostics; import software.amazon.smithy.lsp.editor.SmartInput; import software.amazon.smithy.lsp.ext.Completions; -import software.amazon.smithy.lsp.ext.Constants; import software.amazon.smithy.lsp.ext.Document; import software.amazon.smithy.lsp.ext.DocumentPreamble; import software.amazon.smithy.lsp.ext.LspLog; -import software.amazon.smithy.lsp.ext.SmithyBuildLoader; +import software.amazon.smithy.lsp.ext.SmithyCompletionItem; import software.amazon.smithy.lsp.ext.SmithyProject; -import software.amazon.smithy.lsp.ext.model.SmithyBuildExtensions; import software.amazon.smithy.model.Model; -import software.amazon.smithy.model.SourceLocation; import software.amazon.smithy.model.knowledge.NeighborProviderIndex; import software.amazon.smithy.model.loader.ParserUtils; import software.amazon.smithy.model.neighbor.Walker; @@ -97,34 +82,21 @@ import software.amazon.smithy.model.shapes.ShapeId; import software.amazon.smithy.model.shapes.ShapeType; import software.amazon.smithy.model.shapes.SmithyIdlModelSerializer; -import software.amazon.smithy.model.validation.ValidatedResult; +import software.amazon.smithy.model.traits.Trait; import software.amazon.smithy.model.validation.ValidationEvent; +import software.amazon.smithy.utils.ListUtils; import software.amazon.smithy.utils.SimpleParser; public class SmithyTextDocumentService implements TextDocumentService { - private final List baseCompletions = new ArrayList<>(); private Optional client; - private final List noLocations = Collections.emptyList(); - @Nullable private SmithyProject project; - private final File temporaryFolder; - - // when files are edited, their contents will be persisted in memory and removed - // on didSave or didClose - private final Map temporaryContents = new ConcurrentHashMap<>(); - - // We use this function to hash filepaths to the same location in temporary - // folder - private final HashFunction hash = Hashing.murmur3_128(); /** * @param client Language Client to be used by text document service. - * @param tempFile Temporary File to be used by text document service. */ - public SmithyTextDocumentService(Optional client, File tempFile) { + public SmithyTextDocumentService(Optional client) { this.client = client; - this.temporaryFolder = tempFile; } public void setProject(SmithyProject project) { @@ -139,52 +111,6 @@ public Optional getRoot() { return Optional.ofNullable(project).map(SmithyProject::getRoot); } - /** - * Processes extensions. - *

- * 1. Downloads external dependencies as jars 2. Creates a model from just - * external jars 3. Updates locations index with symbols found in external jars - * - * @param ext extensions - * @param root workspace root - */ - public void createProject(SmithyBuildExtensions ext, File root) { - DependencyResolver resolver = createDependencyResolver(root, ext.getLastModifiedInMillis()); - Either loaded = SmithyProject.load(ext, root, resolver); - if (loaded.isRight()) { - SmithyProject project = loaded.getRight(); - this.project = project; - clearAllDiagnostics(); - sendInfo("Project loaded with " + project.getExternalJars().size() + " external jars and " - + project.getSmithyFiles().size() + " discovered smithy files"); - } else { - sendError( - "Failed to create Smithy project. See output panel for details. Uncaught exception: " - + loaded.getLeft().toString() - ); - loaded.getLeft().printStackTrace(); - } - } - - private DependencyResolver createDependencyResolver(File root, long lastModified) { - Path buildPath = Paths.get(root.toString(), "build", "smithy"); - File buildDir = new File(buildPath.toString()); - if (!buildDir.exists()) { - buildDir.mkdirs(); - } - Path cachePath = Paths.get(buildPath.toString(), "classpath.json"); - File dependencyCache = new File(cachePath.toString()); - if (!dependencyCache.exists()) { - try { - Files.createFile(cachePath); - } catch (IOException e) { - LspLog.println("Could not create dependency cache file " + e); - } - } - MavenDependencyResolver delegate = new MavenDependencyResolver(); - return new FileCacheResolver(dependencyCache, lastModified, delegate); - } - /** * Discovers Smithy build files and loads the smithy project defined by them. * @@ -192,56 +118,40 @@ private DependencyResolver createDependencyResolver(File root, long lastModified */ public void createProject(File root) { LspLog.println("Recreating project from " + root); - SmithyBuildExtensions.Builder result = SmithyBuildExtensions.builder(); - List brokenFiles = new ArrayList<>(); - - for (String file : Constants.BUILD_FILES) { - File smithyBuild = Paths.get(root.getAbsolutePath(), file).toFile(); - if (smithyBuild.isFile()) { - try { - SmithyBuildExtensions local = SmithyBuildLoader.load(smithyBuild.toPath()); - result.merge(local); - LspLog.println("Loaded build extensions " + local + " from " + smithyBuild.getAbsolutePath()); - } catch (Exception e) { - LspLog.println("Failed to load config from" + smithyBuild + ": " + e); - e.printStackTrace(); - brokenFiles.add(smithyBuild.toString()); - } - } - } - - if (brokenFiles.isEmpty()) { - createProject(result.build(), root); + SmithyProject project = SmithyProject.forDirectory(root); + this.project = project; + if (project.isBroken()) { + sendError("Failed to load the build. Encountered the following problems:\n" + + String.join("\n", project.getErrors())); } else { - sendError( - "Failed to load the build, the following build files have problems: \n" - + String.join("\n", brokenFiles) - ); + report(Either.forRight(createPerFileDiagnostics(this.project))); + sendInfo("Project loaded with " + project.getExternalJars().size() + " external jars and " + + project.getSmithyFiles().size() + " discovered smithy files"); } } - private MessageParams msg(final MessageType sev, final String cont) { - return new MessageParams(sev, cont); - } - @Override public CompletableFuture, CompletionList>> completion(CompletionParams params) { LspLog.println("Asking to complete " + params + " in class " + params.getTextDocument().getClass()); try { - String documentUri = params.getTextDocument().getUri(); + URI documentUri = URI.create(params.getTextDocument().getUri()); String token = findToken(documentUri, params.getPosition()); DocumentPreamble preamble = Document.detectPreamble(textBufferContents(documentUri)); boolean isTraitShapeId = isTraitShapeId(documentUri, params.getPosition()); - Optional target = Optional.empty(); + Optional target; if (isTraitShapeId) { target = getTraitTarget(documentUri, params.getPosition(), preamble.getCurrentNamespace()); + } else { + target = Optional.empty(); } - List items = Completions.resolveImports(project.getCompletions(token, isTraitShapeId, - target), - preamble); + List smithyCompletionItems = Completions.find(this.project.getModel(), token, + isTraitShapeId, target); + + List items = Completions.resolveImports(smithyCompletionItems, preamble); + LspLog.println("Completion items: " + items); return Utils.completableFuture(Either.forLeft(items)); @@ -250,12 +160,11 @@ public CompletableFuture, CompletionList>> completio "Failed to identify token for completion in " + params.getTextDocument().getUri() + ": " + e); e.printStackTrace(); } - return Utils.completableFuture(Either.forLeft(baseCompletions)); + return Utils.completableFuture(Either.forLeft(ListUtils.of())); } // Determine the target of a trait, if present. - private Optional getTraitTarget(String documentUri, Position position, Optional namespace) - throws IOException { + private Optional getTraitTarget(URI documentUri, Position position, Optional namespace) { List contents = textBufferContents(documentUri); String currentLine = contents.get(position.getLine()).trim(); if (currentLine.startsWith("apply")) { @@ -281,7 +190,7 @@ private Optional getTraitTarget(String documentUri, Position position, while (originalLine.charAt(offset) == ' ') { offset++; } - return project.getShapeIdFromLocation(documentUri, new Position(i, offset)); + return project.getShapeIdFromLocation(documentUri.toString(), new Position(i, offset)); } } return Optional.empty(); @@ -297,10 +206,7 @@ private Optional getApplyStatementTarget(String applyStatement, Optiona parser.expect('y'); parser.ws(); String name = ParserUtils.parseShapeId(parser); - if (namespace.isPresent()) { - return Optional.of(ShapeId.fromParts(namespace.get(), name)); - } - return Optional.empty(); + return namespace.map(s -> ShapeId.fromParts(s, name)); } // Find the line where the trait ends. @@ -339,8 +245,8 @@ private boolean hasClosingParen(String line) { } // Work backwards from current position to determine if position is part of a trait shapeId. - private boolean isTraitShapeId(String documentUri, Position position) throws IOException { - String line = getLine(textBufferContents(documentUri), position); + private boolean isTraitShapeId(URI documentUri, Position position) { + String line = textBufferContents(documentUri).get(position.getLine()); for (int i = position.getCharacter() - 1; i >= 0; i--) { char c = line.charAt(i); if (c == '@') { @@ -358,43 +264,13 @@ public CompletableFuture resolveCompletionItem(CompletionItem un return Utils.completableFuture(unresolved); } - private List readAll(File f) throws IOException { - return Files.readAllLines(f.toPath()); + private List textBufferContents(URI uri) { + String modelFileContents = this.project.getModelFiles().getOrDefault(uri, ""); + return Arrays.asList(modelFileContents.split(System.lineSeparator())); } - private File designatedTemporaryFile(File source) { - String hashed = hash.hashString(source.getAbsolutePath(), StandardCharsets.UTF_8).toString(); - - return new File(this.temporaryFolder, hashed + Constants.SMITHY_EXTENSION); - } - - /** - * @return lines in the file or buffer - */ - private List textBufferContents(String path) throws IOException { - List contents; - if (Utils.isSmithyJarFile(path)) { - contents = Utils.jarFileContents(path); - } else { - String tempContents = temporaryContents.get(fileFromUri(path)); - if (tempContents != null) { - LspLog.println("Path " + path + " was found in temporary buffer"); - contents = Arrays.stream(tempContents.split("\n")).collect(Collectors.toList()); - } else { - try { - contents = readAll(new File(URI.create(path))); - } catch (IllegalArgumentException e) { - contents = readAll(new File(path)); - } - } - - } - - return contents; - } - - private String findToken(String path, Position p) throws IOException { - List contents = textBufferContents(path); + private String findToken(URI uri, Position p) { + List contents = textBufferContents(uri); String line = contents.get(p.getLine()); int col = p.getCharacter(); @@ -402,7 +278,7 @@ private String findToken(String path, Position p) throws IOException { LspLog.println("Trying to find a token in line '" + line + "' at position " + p); String before = line.substring(0, col); - String after = line.substring(col, line.length()); + String after = line.substring(col); StringBuilder beforeAcc = new StringBuilder(); StringBuilder afterAcc = new StringBuilder(); @@ -433,10 +309,6 @@ private String findToken(String path, Position p) throws IOException { return beforeAcc.reverse().append(afterAcc).toString(); } - private String getLine(List lines, Position position) { - return lines.get(position.getLine()); - } - @Override public CompletableFuture>> documentSymbol( DocumentSymbolParams params @@ -447,7 +319,10 @@ public CompletableFuture>> docume List symbols = new ArrayList<>(); - URI documentUri = documentIdentifierToUri(params.getTextDocument()); + String uriString = params.getTextDocument().getUri(); + URI documentUri = Utils.isSmithyJarFile(uriString) + ? URI.create(URLDecoder.decode(uriString, StandardCharsets.UTF_8.name())) + : this.fileFromUri(uriString).toURI(); locations.forEach((shapeId, loc) -> { String[] locSegments = loc.getUri().replace("\\", "/").split(":"); @@ -483,12 +358,6 @@ public CompletableFuture>> docume } } - private URI documentIdentifierToUri(TextDocumentIdentifier ident) throws UnsupportedEncodingException { - return Utils.isSmithyJarFile(ident.getUri()) - ? URI.create(URLDecoder.decode(ident.getUri(), StandardCharsets.UTF_8.name())) - : this.fileUri(ident).toURI(); - } - @Override public CompletableFuture, List>> definition( DefinitionParams params) { @@ -504,7 +373,8 @@ public CompletableFuture, List locations; Optional initialShapeId = project.getShapeIdFromLocation(params.getTextDocument().getUri(), params.getPosition()); - String found = findToken(params.getTextDocument().getUri(), params.getPosition()); + URI uri = URI.create(params.getTextDocument().getUri()); + String found = findToken(uri, params.getPosition()); if (initialShapeId.isPresent()) { Model model = project.getModel().unwrap(); Shape initialShape = model.getShape(initialShapeId.get()).get(); @@ -529,7 +399,7 @@ public CompletableFuture, List hover(HoverParams params) { Hover hover = new Hover(); MarkupContent content = new MarkupContent(); content.setKind("markdown"); - Optional initialShapeId = project.getShapeIdFromLocation(params.getTextDocument().getUri(), - params.getPosition()); + URI uri = URI.create(params.getTextDocument().getUri()); + Optional initialShapeId = project.getShapeIdFromLocation(uri.toString(), params.getPosition()); + // TODO More granular error handling try { Shape shapeToSerialize; Model model = project.getModel().unwrap(); - String token = findToken(params.getTextDocument().getUri(), params.getPosition()); + String token = findToken(uri, params.getPosition()); LspLog.println("Found token: " + token); if (initialShapeId.isPresent()) { Shape initialShape = model.getShape(initialShapeId.get()).get(); @@ -580,7 +451,7 @@ private Optional getTargetShape(Shape initialShape, String token, Model m .flatMap(shape -> { if (shape.isMemberShape()) { return shape.getAllTraits().values().stream() - .map(trait -> trait.toShapeId()); + .map(Trait::toShapeId); } else { return Stream.of(shape.getId()); } @@ -636,69 +507,46 @@ public CompletableFuture>> codeAction(CodeActio @Override public void didChange(DidChangeTextDocumentParams params) { - File original = fileUri(params.getTextDocument()); - File tempFile = null; - - try { - if (params.getContentChanges().size() > 0) { - tempFile = designatedTemporaryFile(original); - String contents = params.getContentChanges().get(0).getText(); - - unstableContents(original, contents); - - Files.write(tempFile.toPath(), contents.getBytes()); - } - - } catch (Exception e) { - LspLog.println("Failed to write temporary contents for file " + original + " into temporary file " - + tempFile + " : " + e); - e.printStackTrace(); + File file = fileFromUri(params.getTextDocument().getUri()); + if (params.getContentChanges().size() > 0) { + String contents = params.getContentChanges().get(0).getText(); + SmithyProject reloaded = this.project.reloadWithChanges(file.toURI(), contents); + report(handleReloadedProject(reloaded)); } - - report(recompile(original, Optional.ofNullable(tempFile))); - } - - private void stableContents(File file) { - this.temporaryContents.remove(file); - } - - private void unstableContents(File file, String contents) { - LspLog.println("Hashed filename " + file + " into " + designatedTemporaryFile(file)); - this.temporaryContents.put(file, contents); } @Override public void didOpen(DidOpenTextDocumentParams params) { - String rawUri = params.getTextDocument().getUri(); - if (Utils.isFile(rawUri)) { - report(recompile(fileUri(params.getTextDocument()), Optional.empty())); + String uri = params.getTextDocument().getUri(); + String contents = params.getTextDocument().getText(); + if (Utils.isFile(uri)) { + SmithyProject reloaded = this.project.reloadWithChanges(URI.create(uri), contents); + report(handleReloadedProject(reloaded)); } } @Override public void didClose(DidCloseTextDocumentParams params) { - File file = fileUri(params.getTextDocument()); - stableContents(file); - report(recompile(file, Optional.empty())); + SmithyProject reloaded = this.project.reload(); + report(handleReloadedProject(reloaded)); } @Override public void didSave(DidSaveTextDocumentParams params) { - File file = fileUri(params.getTextDocument()); - stableContents(file); - report(recompile(file, Optional.empty())); + SmithyProject reloaded = this.project.reload(); + report(handleReloadedProject(reloaded)); } @Override public CompletableFuture> formatting(DocumentFormattingParams params) { - File file = fileUri(params.getTextDocument()); + File file = fileFromUri(params.getTextDocument().getUri()); final CompletableFuture> emptyResult = Utils.completableFuture(Collections.emptyList()); final Optional content = Utils.optOr( - Optional.ofNullable(temporaryContents.get(file)).map(SmartInput::fromInput), - () -> SmartInput.fromPathSafe(file.toPath()) - ); + Optional.ofNullable(this.project.getModelFiles().get(file.toURI())) + .map(SmartInput::fromInput), () -> SmartInput.fromPathSafe(file.toPath())); + if (content.isPresent()) { SmartInput input = content.get(); final Result result = Formatter.format(input.getInput()); @@ -720,14 +568,6 @@ public CompletableFuture> formatting(DocumentFormatting } } - private File fileUri(TextDocumentIdentifier tdi) { - return fileFromUri(tdi.getUri()); - } - - private File fileUri(TextDocumentItem tdi) { - return fileFromUri(tdi.getUri()); - } - private File fileFromUri(String uri) { try { return new File(URI.create(uri)); @@ -740,31 +580,39 @@ private File fileFromUri(String uri) { * @param result Either a fatal error message, or a list of diagnostics to * publish */ - public void report(Either> result) { + private void report(Either> result) { client.ifPresent(cl -> { - if (result.isLeft()) { - cl.showMessage(msg(MessageType.Error, result.getLeft())); + cl.showMessage(new MessageParams(MessageType.Error, result.getLeft())); } else { result.getRight().forEach(cl::publishDiagnostics); } }); } + private Either> handleReloadedProject(SmithyProject result) { + // TODO: For now, don't update the project unless it isn't broken. We will have to see if this is a good + // experience or not. + if (result.isBroken()) { + return Either.forLeft("Failed to load project:\n" + String.join("\n", result.getErrors())); + } + this.project = result; + return Either.forRight(createPerFileDiagnostics(result)); + } + /** * Breaks down a list of validation events into a per-file list of diagnostics, * explicitly publishing an empty list of diagnostics for files not present in * validation events. * - * @param events output of the Smithy model builder - * @param allFiles all the files registered for the project + * @param project Smithy project to create per file diagnostics for * @return a list of LSP diagnostics to publish */ - public List createPerFileDiagnostics(List events, List allFiles) { + static List createPerFileDiagnostics(SmithyProject project) { // URI is used because conversion toString deals with platform specific path separator Map> byUri = new HashMap<>(); - for (ValidationEvent ev : events) { + for (ValidationEvent ev : project.getModel().getValidationEvents()) { URI finalUri; try { // can be a uri in the form of jar:file:/some-path @@ -772,9 +620,9 @@ public List createPerFileDiagnostics(List createPerFileDiagnostics(List { - List versionDiagnostics = VersionDiagnostics.createVersionDiagnostics(f, temporaryContents); + project.getSmithyFiles().forEach(f -> { + List versionDiagnostics = VersionDiagnostics.createVersionDiagnostics(f, + project.getModelFiles()); if (!byUri.containsKey(f.toURI())) { byUri.put(f.toURI(), versionDiagnostics); } else { @@ -803,79 +652,6 @@ public List createPerFileDiagnostics(List diagnostics = new ArrayList<>(); byUri.forEach((key, value) -> diagnostics.add(new PublishDiagnosticsParams(key.toString(), value))); return diagnostics; - - } - - public void clearAllDiagnostics() { - report(Either.forRight(createPerFileDiagnostics(this.project.getModel().getValidationEvents(), - this.project.getSmithyFiles()))); - } - - /** - * Main recompilation method, responsible for reloading the model, persisting it - * if necessary, and massaging validation events into publishable diagnostics. - * - * @param path file that triggered recompilation - * @param temporary optional location of a temporary file with most recent - * contents - * @return either a fatal error message, or a list of diagnostics - */ - public Either> recompile(File path, Optional temporary) { - // File latestContents = temporary.orElse(path); - Either loadedModel; - if (!temporary.isPresent()) { - // if there's no temporary file present (didOpen/didClose/didSave) - // we want to rebuild the model with the original path - // optionally removing a temporary file - // This protects against a conflict during the didChange -> didSave sequence - loadedModel = this.project.recompile(path, designatedTemporaryFile(path)); - } else { - // If there's a temporary file present (didChange), we want to - // replace the original path with a temporary one (to avoid conflicting - // definitions) - loadedModel = this.project.recompile(temporary.get(), path); - } - - if (loadedModel.isLeft()) { - return Either.forLeft(path + " is not okay!" + loadedModel.getLeft().toString()); - } else { - ValidatedResult result = loadedModel.getRight().getModel(); - // If we're working with a temporary file, we don't want to persist the result - // of the project - if (!temporary.isPresent()) { - this.project = loadedModel.getRight(); - } - - List events = new ArrayList<>(); - List allFiles; - - if (temporary.isPresent()) { - allFiles = project.getSmithyFiles().stream().filter(f -> !f.equals(temporary.get())) - .collect(Collectors.toList()); - // We need to remap some validation events - // from temporary files to the one on which didChange was invoked - for (ValidationEvent ev : result.getValidationEvents()) { - if (ev.getSourceLocation().getFilename().equals(temporary.get().getAbsolutePath())) { - SourceLocation sl = new SourceLocation(path.getAbsolutePath(), ev.getSourceLocation().getLine(), - ev.getSourceLocation().getColumn()); - ValidationEvent newEvent = ev.toBuilder().sourceLocation(sl).build(); - - events.add(newEvent); - } else { - events.add(ev); - } - } - } else { - events.addAll(result.getValidationEvents()); - allFiles = project.getSmithyFiles(); - } - - LspLog.println( - "Recompiling " + path + " (with temporary content " + temporary + ") raised " + events.size() - + " diagnostics"); - return Either.forRight(createPerFileDiagnostics(events, allFiles)); - } - } /** diff --git a/src/main/java/software/amazon/smithy/lsp/Utils.java b/src/main/java/software/amazon/smithy/lsp/Utils.java index f79a203c..6c3e5395 100644 --- a/src/main/java/software/amazon/smithy/lsp/Utils.java +++ b/src/main/java/software/amazon/smithy/lsp/Utils.java @@ -20,6 +20,7 @@ import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; +import java.io.UncheckedIOException; import java.io.UnsupportedEncodingException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; @@ -81,6 +82,19 @@ public static String toSmithyJarFile(String uri) { return "smithyjar:" + uri.substring(9); } + /** + * @param uri Uri of jar file to read, with scheme part "jar:file:" + * @return Jar file contents + */ + public static String readJarFile(String uri) { + String strippedUri = uri.replaceFirst("jar:file:", ""); + try { + return String.join(System.lineSeparator(), jarFileContents(strippedUri)); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + /** * @param rawUri String * @return Returns whether the uri points to a file in the filesystem (as diff --git a/src/main/java/software/amazon/smithy/lsp/diagnostics/VersionDiagnostics.java b/src/main/java/software/amazon/smithy/lsp/diagnostics/VersionDiagnostics.java index 898f1c60..ec1324bb 100644 --- a/src/main/java/software/amazon/smithy/lsp/diagnostics/VersionDiagnostics.java +++ b/src/main/java/software/amazon/smithy/lsp/diagnostics/VersionDiagnostics.java @@ -17,6 +17,7 @@ import java.io.File; import java.io.IOException; +import java.net.URI; import java.util.Collections; import java.util.List; import java.util.Map; @@ -93,10 +94,10 @@ public static Diagnostic defineVersion(Range range) { * @param temporaryContents a map of file to content (represent opened file that are not saved) * @return a list of PublishDiagnosticsParams */ - public static List createVersionDiagnostics(File f, Map temporaryContents) { + public static List createVersionDiagnostics(File f, Map temporaryContents) { // number of line to read in which we expect the $version statement int n = 5; - String editedContent = temporaryContents.get(f); + String editedContent = temporaryContents.get(f.toURI()); List lines; try { diff --git a/src/main/java/software/amazon/smithy/lsp/ext/Completions.java b/src/main/java/software/amazon/smithy/lsp/ext/Completions.java index 86a9d564..0328bda9 100644 --- a/src/main/java/software/amazon/smithy/lsp/ext/Completions.java +++ b/src/main/java/software/amazon/smithy/lsp/ext/Completions.java @@ -41,6 +41,7 @@ import software.amazon.smithy.model.shapes.UnionShape; import software.amazon.smithy.model.traits.RequiredTrait; import software.amazon.smithy.model.traits.TraitDefinition; +import software.amazon.smithy.model.validation.ValidatedResult; import software.amazon.smithy.utils.ListUtils; public final class Completions { @@ -52,18 +53,26 @@ private Completions() { } /** - * From a model and (potentially partial) token, build a list of completions. - * Empty list is returned for empty tokens. Current implementation is prefix - * based. + * From a model validation result and (potentially partial) token, build a list of completions. + * Empty list is returned for empty tokens, or if the model validation result doesn't contain a model. Current + * implementation is prefix based. * - * @param model Smithy model + * @param modelValidatedResult Smithy model validation result * @param token token * @param isTraitShapeId boolean * @param target Optional ShapeId of the target trait target * @return list of completion items */ - public static List find(Model model, String token, boolean isTraitShapeId, - Optional target) { + public static List find( + ValidatedResult modelValidatedResult, + String token, + boolean isTraitShapeId, + Optional target + ) { + if (!modelValidatedResult.getResult().isPresent()) { + return ListUtils.of(); + } + Model model = modelValidatedResult.getResult().get(); Map comps = new HashMap<>(); String lcase = token.toLowerCase(); diff --git a/src/main/java/software/amazon/smithy/lsp/ext/SmithyProject.java b/src/main/java/software/amazon/smithy/lsp/ext/SmithyProject.java index 3f31d639..74ee99c3 100644 --- a/src/main/java/software/amazon/smithy/lsp/ext/SmithyProject.java +++ b/src/main/java/software/amazon/smithy/lsp/ext/SmithyProject.java @@ -17,14 +17,14 @@ import java.io.File; import java.io.IOException; +import java.net.URI; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; -import java.util.Collection; import java.util.Collections; import java.util.Comparator; -import java.util.HashSet; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; @@ -38,87 +38,160 @@ import software.amazon.smithy.build.model.MavenRepository; import software.amazon.smithy.cli.EnvironmentVariable; import software.amazon.smithy.cli.dependencies.DependencyResolver; +import software.amazon.smithy.cli.dependencies.FileCacheResolver; +import software.amazon.smithy.cli.dependencies.MavenDependencyResolver; import software.amazon.smithy.lsp.SmithyInterface; +import software.amazon.smithy.lsp.Utils; import software.amazon.smithy.lsp.ext.model.SmithyBuildExtensions; import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.SourceLocation; import software.amazon.smithy.model.selector.Selector; import software.amazon.smithy.model.shapes.Shape; import software.amazon.smithy.model.shapes.ShapeId; import software.amazon.smithy.model.validation.ValidatedResult; import software.amazon.smithy.model.validation.ValidatedResultException; +import software.amazon.smithy.utils.IoUtils; +import software.amazon.smithy.utils.ListUtils; +import software.amazon.smithy.utils.MapUtils; public final class SmithyProject { private static final MavenRepository CENTRAL = MavenRepository.builder() .url("https://repo.maven.apache.org/maven2") .build(); + private final File root; private final List imports; private final List smithyFiles; private final List externalJars; - private Map locations = Collections.emptyMap(); + private final Map modelFiles; private final ValidatedResult model; - private final File root; + private final Map locations; + private final List errors; - SmithyProject( + private SmithyProject( + File root, List imports, List smithyFiles, List externalJars, - File root, - ValidatedResult model + Map modelFiles, + ValidatedResult model, + Map locations, + List errors ) { - this.imports = imports; this.root = root; - this.model = model; + this.imports = imports; this.smithyFiles = smithyFiles; this.externalJars = externalJars; - model.getResult().ifPresent(m -> this.locations = collectLocations(m)); + this.modelFiles = modelFiles; + this.model = model; + this.locations = locations; + this.errors = errors; } /** - * Recompile the model, adding a file to list of tracked files, potentially - * excluding some other file. - *

- * This version of the method above is used when the - * file is in ephemeral storage (temporary location when file is being changed) + * Loads the Smithy project in the given directory. * - * @param changed file which may or may not be already tracked by this project. - * @param exclude file to exclude from being recompiled. - * @return either an error, or a loaded project. + * @param root Directory to load project from. + * @return The loaded Smithy project. */ - public Either recompile(File changed, File exclude) { - HashSet fileSet = new HashSet<>(); - - for (File existing : onlyExistingFiles(this.smithyFiles)) { - if (exclude != null && !existing.equals(exclude)) { - fileSet.add(existing); + public static SmithyProject forDirectory(File root) { + SmithyBuildExtensions.Builder builder = SmithyBuildExtensions.builder(); + List errors = new ArrayList<>(); + + for (String filename: Constants.BUILD_FILES) { + File smithyBuild = Paths.get(root.getAbsolutePath(), filename).toFile(); + if (smithyBuild.isFile()) { + try { + SmithyBuildExtensions local = SmithyBuildLoader.load(smithyBuild.toPath()); + builder.merge(local); + LspLog.println("Loaded smithy-build config" + local + " from " + smithyBuild.getAbsolutePath()); + } catch (Exception e) { + errors.add("Failed to load config from" + smithyBuild + ": " + e); + } } } - if (changed.isFile()) { - fileSet.add(changed); + if (!errors.isEmpty()) { + return new SmithyProject( + root, + ListUtils.of(), + ListUtils.of(), + ListUtils.of(), + MapUtils.of(), + ValidatedResult.empty(), + MapUtils.of(), + errors); } - return load(this.imports, new ArrayList<>(fileSet), this.externalJars, this.root); - } - - public ValidatedResult getModel() { - return this.model; + SmithyBuildExtensions smithyBuild = builder.build(); + DependencyResolver resolver = createDependencyResolver(root, smithyBuild.getLastModifiedInMillis()); + return load(smithyBuild, root, resolver); } - public List getExternalJars() { - return this.externalJars; + private static DependencyResolver createDependencyResolver(File root, long lastModified) { + Path buildPath = Paths.get(root.toString(), "build", "smithy"); + File buildDir = new File(buildPath.toString()); + if (!buildDir.exists()) { + buildDir.mkdirs(); + } + Path cachePath = Paths.get(buildPath.toString(), "classpath.json"); + File dependencyCache = new File(cachePath.toString()); + if (!dependencyCache.exists()) { + try { + Files.createFile(cachePath); + } catch (IOException e) { + LspLog.println("Could not create dependency cache file " + e); + } + } + MavenDependencyResolver delegate = new MavenDependencyResolver(); + return new FileCacheResolver(dependencyCache, lastModified, delegate); } - public List getSmithyFiles() { - return this.smithyFiles; + /** + * Reload the model. + * + * @return The loaded project. + */ + public SmithyProject reload() { + return load( + SmithyInterface.readModel(smithyFiles, externalJars), + this.imports, + onlyExistingFiles(this.smithyFiles), + this.externalJars, + this.root, + this.modelFiles + ); } - public List getCompletions(String token, boolean isTrait, Optional target) { - return this.model.getResult().map(model -> Completions.find(model, token, isTrait, target)) - .orElse(Collections.emptyList()); - } + /** + * Reloads the project with changes for a specific file. This may be used + * to add new files to the project. + * + * @param changedUri URI of the changed file. + * @param contents Contents of the changed file. + * @return The reloaded project. + */ + public SmithyProject reloadWithChanges(URI changedUri, String contents) { + this.modelFiles.put(changedUri, contents); + // Reload the model using in-memory versions of source files + List existingSourceFiles = onlyExistingFiles(this.smithyFiles); + // Handle the case when the file is new + if (!existingSourceFiles.contains(new File(changedUri))) { + existingSourceFiles.add(new File(changedUri)); + } + Map sources = new HashMap<>(); + for (File sourceFile : existingSourceFiles) { + URI uri = sourceFile.toURI(); + sources.put(uri.getPath(), this.modelFiles.get(uri)); + } - public Map getLocations() { - return this.locations; + return load( + SmithyInterface.readModel(sources, this.externalJars), + this.imports, + existingSourceFiles, + this.externalJars, + this.root, + this.modelFiles + ); } /** @@ -129,9 +202,9 @@ public Map getLocations() { * @param root workspace root. * @return either an error or a loaded project. */ - public static Either load(SmithyBuildExtensions config, File root, - DependencyResolver resolver) { - List imports = config.getImports().stream().map(p -> Paths.get(root.getAbsolutePath(), p).normalize()) + static SmithyProject load(SmithyBuildExtensions config, File root, DependencyResolver resolver) { + List imports = config.getImports().stream() + .map(p -> Paths.get(root.getAbsolutePath(), p).normalize()) .collect(Collectors.toList()); if (imports.isEmpty()) { @@ -146,63 +219,108 @@ public static Either load(SmithyBuildExtensions config List externalJars = downloadExternalDependencies(config, resolver); LspLog.println("Downloaded external jars: " + externalJars); - return load(imports, smithyFiles, externalJars, root); + Either> readModelResult = SmithyInterface.readModel(smithyFiles, + externalJars); - } - - /** - * Run a selector expression against the loaded model in the workspace. - * @param expression the selector expression. - * @return list of locations of shapes that match expression. - */ - public Either> runSelector(String expression) { - try { - Selector selector = Selector.parse(expression); - Set shapes = selector.select(this.model.unwrap()); - return Either.forRight(shapes.stream() - .map(shape -> this.locations.get(shape.getId())) - .collect(Collectors.toList())); - } catch (ValidatedResultException e) { - return Either.forLeft(e); + Map modelFiles; + if (readModelResult.isRight()) { + modelFiles = readModelFiles(smithyFiles, readModelResult.getRight()); + } else { + modelFiles = MapUtils.of(); } + return load(readModelResult, imports, smithyFiles, externalJars, root, modelFiles); } - private static Either load( + private static SmithyProject load( + Either> loadModelResult, List imports, List smithyFiles, List externalJars, - File root + File root, + Map modelFiles ) { - Either> model = createModel(smithyFiles, externalJars); - - if (model.isLeft()) { - return Either.forLeft(model.getLeft()); - } else { - model.getRight().getValidationEvents().forEach(LspLog::println); - + List errors = ListUtils.of(); + Map definitionLocations = MapUtils.of(); + ValidatedResult model = ValidatedResult.empty(); + if (loadModelResult.isRight()) { + model = loadModelResult.getRight(); + model.getValidationEvents().forEach(LspLog::println); + // TODO: This shouldn't fail, it's only here because location collection is buggy. try { - SmithyProject p = new SmithyProject(imports, smithyFiles, externalJars, root, model.getRight()); - return Either.forRight(p); + definitionLocations = collectLocations(model); } catch (Exception e) { - return Either.forLeft(e); + errors.add("Failed to collect definition locations:\n" + e); } + } else { + errors.add(loadModelResult.getLeft().toString()); } + return new SmithyProject( + root, + imports, + smithyFiles, + externalJars, + modelFiles, + model, + definitionLocations, + errors + ); } - private static Either> createModel( - List discoveredFiles, - List externalJars - ) { - return SmithyInterface.readModel(discoveredFiles, externalJars); + static Map collectLocations(ValidatedResult model) { + if (model.getResult().isPresent()) { + ShapeLocationCollector collector = new FileCachingCollector(); + return collector.collectDefinitionLocations(model.getResult().get()); + } + return MapUtils.of(); + } + + public ValidatedResult getModel() { + return this.model; + } + + public List getExternalJars() { + return this.externalJars; + } + + public List getSmithyFiles() { + return this.smithyFiles; + } + + public Map getModelFiles() { + return this.modelFiles; + } + + public Map getLocations() { + return this.locations; } public File getRoot() { return this.root; } - private static Map collectLocations(Model model) { - ShapeLocationCollector collector = new FileCachingCollector(); - return collector.collectDefinitionLocations(model); + public List getErrors() { + return this.errors; + } + + public boolean isBroken() { + return this.errors.size() > 0; + } + + /** + * Run a selector expression against the loaded model in the workspace. + * @param expression the selector expression. + * @return list of locations of shapes that match expression. + */ + public Either> runSelector(String expression) { + try { + Selector selector = Selector.parse(expression); + Set shapes = selector.select(this.model.unwrap()); + return Either.forRight(shapes.stream() + .map(shape -> this.locations.get(shape.getId())) + .collect(Collectors.toList())); + } catch (ValidatedResultException e) { + return Either.forLeft(e); + } } /** @@ -250,9 +368,10 @@ private static Boolean isValidSmithyFile(Path file) { } private static List walkSmithyFolder(Path path, File root) { - try (Stream walk = Files.walk(path)) { - return walk.filter(Files::isRegularFile).filter(SmithyProject::isValidSmithyFile).map(Path::toFile) + return walk.filter(Files::isRegularFile) + .filter(SmithyProject::isValidSmithyFile) + .map(Path::toFile) .collect(Collectors.toList()); } catch (IOException e) { LspLog.println("Failed to walk import '" + path + "' from root " + root + ": " + e); @@ -262,7 +381,6 @@ private static List walkSmithyFolder(Path path, File root) { private static List discoverSmithyFiles(List imports, File root) { List smithyFiles = new ArrayList<>(); - imports.forEach(path -> { if (Files.isDirectory(path)) { smithyFiles.addAll(walkSmithyFolder(path, root)); @@ -309,7 +427,38 @@ private static void addConfiguredMavenRepos(SmithyBuildExtensions extensions, De } } - private static List onlyExistingFiles(Collection files) { + private static List onlyExistingFiles(List files) { return files.stream().filter(File::isFile).collect(Collectors.toList()); } + + private static Map readModelFiles(List sources, ValidatedResult model) { + Set modelFilesUris = sources.stream().map(File::toURI).collect(Collectors.toSet()); + Set loadedModelFileUris = model.getResult() + .map(Model::shapes) + .orElse(Stream.empty()) + .map(Shape::getSourceLocation) + .map(SourceLocation::getFilename) + .map(filename -> { + // modelFileUris MUST contain the scheme part. If they aren't present, + // use using File::toURI adds them and is portable. + if (filename.startsWith("jar:") || filename.startsWith("file:")) { + return URI.create(filename); + } else { + return new File(filename).toURI(); + } + }) + .collect(Collectors.toSet()); + modelFilesUris.addAll(loadedModelFileUris); + Map modelFiles = new HashMap<>(); + for (URI uri : modelFilesUris) { + String contents; + if (Utils.isJarFile(uri.toString())) { + contents = Utils.readJarFile(uri.toString()); + } else { + contents = IoUtils.readUtf8File(uri.getPath()); + } + modelFiles.put(uri, contents); + } + return modelFiles; + } } diff --git a/src/test/java/software/amazon/smithy/lsp/DefinitionProviderTest.java b/src/test/java/software/amazon/smithy/lsp/DefinitionProviderTest.java index e3026f1f..3ec5344b 100644 --- a/src/test/java/software/amazon/smithy/lsp/DefinitionProviderTest.java +++ b/src/test/java/software/amazon/smithy/lsp/DefinitionProviderTest.java @@ -157,9 +157,9 @@ private static List getDefinitionLocations(Path rootDir, Str Path model = rootDir.resolve(filename); try (Harness hs = Harness.builder().paths(model).build()) { StubClient client = new StubClient(); - SmithyTextDocumentService tds = new SmithyTextDocumentService(Optional.of(client), hs.getTempFolder()); + SmithyTextDocumentService tds = new SmithyTextDocumentService(Optional.of(client)); tds.setProject(hs.getProject()); - TextDocumentIdentifier tdi = new TextDocumentIdentifier(hs.file(filename).toString()); + TextDocumentIdentifier tdi = new TextDocumentIdentifier(hs.file(filename).toURI().toString()); DefinitionParams params = getDefinitionParams(tdi, line, column); try { return tds.definition(params).get().getLeft(); diff --git a/src/test/java/software/amazon/smithy/lsp/HoverProviderTest.java b/src/test/java/software/amazon/smithy/lsp/HoverProviderTest.java index e6972eef..8f6d2953 100644 --- a/src/test/java/software/amazon/smithy/lsp/HoverProviderTest.java +++ b/src/test/java/software/amazon/smithy/lsp/HoverProviderTest.java @@ -238,9 +238,9 @@ private static MarkupContent getHoverContent( paths.add(rootDir.resolve(filename)); try (Harness hs = builder.paths(paths).build()) { StubClient client = new StubClient(); - SmithyTextDocumentService tds = new SmithyTextDocumentService(Optional.of(client), hs.getTempFolder()); + SmithyTextDocumentService tds = new SmithyTextDocumentService(Optional.of(client)); tds.setProject(hs.getProject()); - TextDocumentIdentifier tdi = new TextDocumentIdentifier(hs.file(filename).toString()); + TextDocumentIdentifier tdi = new TextDocumentIdentifier(hs.file(filename).toURI().toString()); HoverParams params = new HoverParams(tdi, new Position(line, column)); try { diff --git a/src/test/java/software/amazon/smithy/lsp/SmithyTextDocumentServiceTest.java b/src/test/java/software/amazon/smithy/lsp/SmithyTextDocumentServiceTest.java index 9a35f49a..4ac9fbec 100644 --- a/src/test/java/software/amazon/smithy/lsp/SmithyTextDocumentServiceTest.java +++ b/src/test/java/software/amazon/smithy/lsp/SmithyTextDocumentServiceTest.java @@ -71,23 +71,11 @@ public void correctlyAttributingDiagnostics() { MapUtils.entry(goodFileName, "$version: \"2\"\nnamespace testBla")); try (Harness hs = Harness.builder().files(files).build()) { - SmithyTextDocumentService tds = new SmithyTextDocumentService(Optional.empty(), hs.getTempFolder()); - tds.setProject(hs.getProject()); - - File broken = hs.file(brokenFileName); - File good = hs.file(goodFileName); - - // When compiling broken file - Set filesWithDiagnostics = tds.recompile(broken, Optional.empty()).getRight().stream() - .filter(pds -> (pds.getDiagnostics().size() > 0)).map(PublishDiagnosticsParams::getUri) - .collect(Collectors.toSet()); - assertEquals(SetUtils.of(uri(broken)), filesWithDiagnostics); - - // When compiling good file - filesWithDiagnostics = tds.recompile(good, Optional.empty()).getRight().stream() + Set filesWithDiagnostics = SmithyTextDocumentService.createPerFileDiagnostics(hs.getProject()) + .stream() .filter(pds -> (pds.getDiagnostics().size() > 0)).map(PublishDiagnosticsParams::getUri) .collect(Collectors.toSet()); - assertEquals(SetUtils.of(uri(broken)), filesWithDiagnostics); + assertEquals(SetUtils.of(uri(hs.file(brokenFileName))), filesWithDiagnostics); } } @@ -102,7 +90,7 @@ public void sendingDiagnosticsToTheClient() { try (Harness hs = Harness.builder().files(files).build()) { StubClient client = new StubClient(); - SmithyTextDocumentService tds = new SmithyTextDocumentService(Optional.of(client), hs.getTempFolder()); + SmithyTextDocumentService tds = new SmithyTextDocumentService(Optional.of(client)); tds.setProject(hs.getProject()); File broken = hs.file(brokenFileName); @@ -142,15 +130,10 @@ public void attributesDiagnosticsForUnknownTraits() throws Exception { String modelFilename = "ext/models/unknown-trait.smithy"; Path modelFilePath = Paths.get(getClass().getResource(modelFilename).toURI()); try (Harness hs = Harness.builder().paths(modelFilePath).build()) { - StubClient client = new StubClient(); - SmithyTextDocumentService tds = new SmithyTextDocumentService(Optional.of(client), hs.getTempFolder()); - tds.setProject(hs.getProject()); - - File modelFile = hs.file(modelFilename); - // There must be one warning diagnostic at the unknown trait's location Range unknownTraitRange = new Range(new Position(6, 0), new Position(6, 0)); - long matchingDiagnostics = tds.recompile(modelFile, Optional.empty()).getRight().stream() + long matchingDiagnostics = SmithyTextDocumentService.createPerFileDiagnostics(hs.getProject()) + .stream() .flatMap(params -> params.getDiagnostics().stream()) .filter(diagnostic -> diagnostic.getSeverity().equals(DiagnosticSeverity.Warning)) .filter(diagnostic -> diagnostic.getRange().equals(unknownTraitRange)) @@ -169,7 +152,7 @@ public void handlingChanges() { try (Harness hs = Harness.builder().files(files).build()) { StubClient client = new StubClient(); - SmithyTextDocumentService tds = new SmithyTextDocumentService(Optional.of(client), hs.getTempFolder()); + SmithyTextDocumentService tds = new SmithyTextDocumentService(Optional.of(client)); tds.setProject(hs.getProject()); File file1 = hs.file(fileName1); @@ -191,10 +174,10 @@ public void completionsV1() throws Exception { Path modelMain = baseDir.resolve(MAIN_MODEL_FILENAME); try (Harness hs = Harness.builder().paths(modelMain).build()) { StubClient client = new StubClient(); - SmithyTextDocumentService tds = new SmithyTextDocumentService(Optional.of(client), hs.getTempFolder()); + SmithyTextDocumentService tds = new SmithyTextDocumentService(Optional.of(client)); tds.setProject(hs.getProject()); - TextDocumentIdentifier mainTdi = new TextDocumentIdentifier(hs.file(MAIN_MODEL_FILENAME).toString()); + TextDocumentIdentifier mainTdi = new TextDocumentIdentifier(hs.file(MAIN_MODEL_FILENAME).toURI().toString()); CompletionParams traitParams = completionParams(mainTdi, 85, 10); List traitCompletionItems = tds.completion(traitParams).get().getLeft(); @@ -227,10 +210,10 @@ public void completionsV2() throws Exception { Path modelMain = baseDir.resolve(MAIN_MODEL_FILENAME); try (Harness hs = Harness.builder().paths(modelMain).build()) { StubClient client = new StubClient(); - SmithyTextDocumentService tds = new SmithyTextDocumentService(Optional.of(client), hs.getTempFolder()); + SmithyTextDocumentService tds = new SmithyTextDocumentService(Optional.of(client)); tds.setProject(hs.getProject()); - TextDocumentIdentifier mainTdi = new TextDocumentIdentifier(hs.file(MAIN_MODEL_FILENAME).toString()); + TextDocumentIdentifier mainTdi = new TextDocumentIdentifier(hs.file(MAIN_MODEL_FILENAME).toURI().toString()); CompletionParams traitParams = completionParams(mainTdi, 87, 10); List traitCompletionItems = tds.completion(traitParams).get().getLeft(); @@ -264,7 +247,7 @@ public void runSelectorV1() { try (Harness hs = Harness.builder().paths(modelMain).build()) { StubClient client = new StubClient(); - SmithyTextDocumentService tds = new SmithyTextDocumentService(Optional.of(client), hs.getTempFolder()); + SmithyTextDocumentService tds = new SmithyTextDocumentService(Optional.of(client)); tds.setProject(hs.getProject()); Either> result = tds.runSelector("[id|namespace=com.foo]"); @@ -288,7 +271,7 @@ public void runSelectorV2() { try (Harness hs = Harness.builder().paths(modelMain).build()) { StubClient client = new StubClient(); - SmithyTextDocumentService tds = new SmithyTextDocumentService(Optional.of(client), hs.getTempFolder()); + SmithyTextDocumentService tds = new SmithyTextDocumentService(Optional.of(client)); tds.setProject(hs.getProject()); tds.setClient(client); @@ -312,7 +295,7 @@ public void runSelectorAgainstModelWithErrorsV1() { Path broken = baseDir.resolve("broken.smithy"); try (Harness hs = Harness.builder().paths(broken).build()) { StubClient client = new StubClient(); - SmithyTextDocumentService tds = new SmithyTextDocumentService(Optional.of(client), hs.getTempFolder()); + SmithyTextDocumentService tds = new SmithyTextDocumentService(Optional.of(client)); tds.setProject(hs.getProject()); Either> result = tds.runSelector("[id|namespace=com.foo]"); @@ -328,7 +311,7 @@ public void runSelectorAgainstModelWithErrorsV2() { Path broken = baseDir.resolve("broken.smithy"); try (Harness hs = Harness.builder().paths(broken).build()) { StubClient client = new StubClient(); - SmithyTextDocumentService tds = new SmithyTextDocumentService(Optional.of(client), hs.getTempFolder()); + SmithyTextDocumentService tds = new SmithyTextDocumentService(Optional.of(client)); tds.setProject(hs.getProject()); Either> result = tds.runSelector("[id|namespace=com.foo]"); @@ -351,9 +334,9 @@ public void ensureVersionDiagnostic() { ); try (Harness hs = Harness.builder().files(files).build()) { - SmithyTextDocumentService tds = new SmithyTextDocumentService(Optional.empty(), hs.getTempFolder()); + SmithyTextDocumentService tds = new SmithyTextDocumentService(Optional.empty()); StubClient client = new StubClient(); - tds.createProject(hs.getConfig(), hs.getRoot()); + tds.setProject(hs.getProject()); tds.setClient(client); tds.didOpen(new DidOpenTextDocumentParams(textDocumentItem(hs.file(fileName1), files.get(fileName1)))); @@ -369,7 +352,6 @@ public void ensureVersionDiagnostic() { tds.didOpen(new DidOpenTextDocumentParams(textDocumentItem(hs.file(fileName3), files.get(fileName3)))); assertEquals(0, fileDiagnostics(hs.file(fileName3), client.diagnostics).size()); } - } @Test @@ -382,7 +364,7 @@ public void documentSymbols() throws Exception { Path anotherFilePath = baseDir.resolve(anotherFile); try (Harness hs = Harness.builder().paths(currentFilePath, anotherFilePath).build()) { - SmithyTextDocumentService tds = new SmithyTextDocumentService(Optional.empty(), hs.getTempFolder()); + SmithyTextDocumentService tds = new SmithyTextDocumentService(Optional.empty()); tds.setProject(hs.getProject()); TextDocumentIdentifier currentDocumentIdent = new TextDocumentIdentifier(uri(hs.file(currentFile))); @@ -398,7 +380,6 @@ public void documentSymbols() throws Exception { assertEquals("Weather", symbols.get(1).getRight().getName()); assertEquals(SymbolKind.Struct, symbols.get(1).getRight().getKind()); } - } private Set getUris(Collection diagnostics) { diff --git a/src/test/java/software/amazon/smithy/lsp/ext/CompletionsTest.java b/src/test/java/software/amazon/smithy/lsp/ext/CompletionsTest.java index f81af8aa..5ed4ef9e 100644 --- a/src/test/java/software/amazon/smithy/lsp/ext/CompletionsTest.java +++ b/src/test/java/software/amazon/smithy/lsp/ext/CompletionsTest.java @@ -27,8 +27,9 @@ import java.util.stream.Collectors; import org.eclipse.lsp4j.CompletionItem; import org.junit.Test; -import software.amazon.smithy.lsp.ext.model.SmithyBuildExtensions; +import software.amazon.smithy.model.Model; import software.amazon.smithy.model.shapes.ShapeId; +import software.amazon.smithy.model.validation.ValidatedResult; import software.amazon.smithy.utils.IoUtils; import software.amazon.smithy.utils.MapUtils; import software.amazon.smithy.utils.SetUtils; @@ -47,15 +48,17 @@ public void resolveCurrentNamespace() throws Exception { ); try (Harness hs = Harness.builder().files(files).build()) { - SmithyProject proj = hs.getProject(); + ValidatedResult model = hs.getProject().getModel(); DocumentPreamble testPreamble = Document.detectPreamble(hs.readFile(hs.file("test/def2.smithy"))); - List itemsWithEdit = Completions.resolveImports(proj.getCompletions("Hello", false, Optional.empty()), + List itemsWithEdit = Completions.resolveImports( + Completions.find(model, "Hello", false, Optional.empty()), testPreamble); assertEquals("\nuse bar#Hello\n", itemsWithEdit.get(0).getAdditionalTextEdits().get(0).getNewText()); DocumentPreamble barPreamble = Document.detectPreamble(hs.readFile(hs.file("bar/def1.smithy"))); - List itemsWithEdit2 = Completions.resolveImports(proj.getCompletions("Hello", false, Optional.empty()), + List itemsWithEdit2 = Completions.resolveImports( + Completions.find(model, "Hello", false, Optional.empty()), barPreamble); assertNull(itemsWithEdit2.get(0).getAdditionalTextEdits()); } @@ -160,7 +163,8 @@ Set completeNames(SmithyProject proj, String token, boolean isTrait, Str if (shapeId != null) { target = Optional.of(ShapeId.from(shapeId)); } - return proj.getCompletions(token, isTrait, target).stream().map(ci -> ci.getCompletionItem().getLabel()) + return Completions.find(proj.getModel(), token, isTrait, target).stream() + .map(ci -> ci.getCompletionItem().getLabel()) .collect(Collectors.toSet()); } } diff --git a/src/test/java/software/amazon/smithy/lsp/ext/Harness.java b/src/test/java/software/amazon/smithy/lsp/ext/Harness.java index f0d383ff..9af7a281 100644 --- a/src/test/java/software/amazon/smithy/lsp/ext/Harness.java +++ b/src/test/java/software/amazon/smithy/lsp/ext/Harness.java @@ -21,14 +21,11 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; -import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Map.Entry; -import org.eclipse.lsp4j.jsonrpc.messages.Either; import software.amazon.smithy.cli.dependencies.DependencyResolver; -import software.amazon.smithy.cli.dependencies.ResolvedArtifact; import software.amazon.smithy.lsp.Utils; import software.amazon.smithy.lsp.ext.model.SmithyBuildExtensions; import software.amazon.smithy.utils.IoUtils; @@ -63,7 +60,6 @@ public SmithyBuildExtensions getConfig() { return this.config; } - public File file(String path) { return Paths.get(root.getAbsolutePath(), path).toFile(); } @@ -155,17 +151,13 @@ public Harness build() { } } - private static Harness loadHarness( - SmithyBuildExtensions ext, - File hs, - File tmp, - DependencyResolver resolver - ) throws Exception { - Either loaded = SmithyProject.load(ext, hs, resolver); - if (loaded.isRight()) - return new Harness(hs, tmp, loaded.getRight(), ext); - else - throw loaded.getLeft(); + private static Harness loadHarness(SmithyBuildExtensions ext, File hs, File tmp, DependencyResolver resolver) { + SmithyProject loaded = SmithyProject.load(ext, hs, resolver); + if (!loaded.isBroken()) { + return new Harness(hs, tmp, loaded, ext); + } else { + throw new RuntimeException(String.join("\n", loaded.getErrors())); + } } private static void safeCreateFile(String path, String contents, File root) throws Exception { diff --git a/src/test/java/software/amazon/smithy/lsp/ext/SmithyProjectTest.java b/src/test/java/software/amazon/smithy/lsp/ext/SmithyProjectTest.java index cfc7719a..95285bd0 100644 --- a/src/test/java/software/amazon/smithy/lsp/ext/SmithyProjectTest.java +++ b/src/test/java/software/amazon/smithy/lsp/ext/SmithyProjectTest.java @@ -23,11 +23,11 @@ import static org.junit.Assert.assertTrue; import java.io.File; +import java.io.FileWriter; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; -import java.util.Collections; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @@ -325,9 +325,7 @@ public void definitionLocationsEmptySourceLocationsOnTraitV1() throws Exception .addShape(stringShapeBaz) .build(); ValidatedResult model = Model.assembler().addModel(unvalidatedModel).assemble(); - SmithyProject project = new SmithyProject(Collections.emptyList(), Collections.emptyList(), - Collections.emptyList(), baseDir.toFile(), model); - Map locationMap = project.getLocations(); + Map locationMap = SmithyProject.collectLocations(model); correctLocation(locationMap, "ns.foo#Bar", 4, 0, 4, 10); correctLocation(locationMap, "ns.foo#Baz", 7, 0, 7, 10); @@ -355,9 +353,7 @@ public void definitionLocationsEmptySourceLocationsOnTraitV2() throws Exception .addShape(stringShapeBaz) .build(); ValidatedResult model = Model.assembler().addModel(unvalidatedModel).assemble(); - SmithyProject project = new SmithyProject(Collections.emptyList(), Collections.emptyList(), - Collections.emptyList(), baseDir.toFile(), model); - Map locationMap = project.getLocations(); + Map locationMap = SmithyProject.collectLocations(model); correctLocation(locationMap, "ns.foo#Bar", 4, 0, 4, 10); correctLocation(locationMap, "ns.foo#Baz", 7, 0, 7, 10); @@ -487,6 +483,202 @@ public void shapeIdFromLocationV2() throws Exception { } } + @Test + public void loadsProjectInDirectory() { + File root = getTestProjectRoot(); + String modelFilename = "main.smithy"; + String modelContents = "$version: \"2\"\nnamespace com.foo\nstructure Foo {}\n"; + File modelFile = createModelFileInModelsDir(root, modelFilename, modelContents); + SmithyProject project = SmithyProject.forDirectory(root); + + assertFalse(project.isBroken()); + assertFalse(project.getModel().isBroken()); + assertEquals(root, project.getRoot()); + assertEquals(1, project.getSmithyFiles().size()); + assertTrue(project.getSmithyFiles().contains(modelFile)); + assertTrue(project.getModelFiles().containsKey(modelFile.toURI())); + assertEquals(modelContents, project.getModelFiles().get(modelFile.toURI())); + } + + @Test + public void reloadsProject() { + File root = getTestProjectRoot(); + String modelFilename = "main.smithy"; + String modelContents = "$version: \"2\"\nnamespace com.foo\nstructure Foo {}\n"; + File modelFile = createModelFileInModelsDir(root, modelFilename, modelContents); + SmithyProject project = SmithyProject.forDirectory(root); + SmithyProject reloaded = project.reload(); + + assertFalse(reloaded.isBroken()); + assertFalse(reloaded.getModel().isBroken()); + assertEquals(root, reloaded.getRoot()); + assertEquals(1, reloaded.getSmithyFiles().size()); + assertTrue(reloaded.getSmithyFiles().contains(modelFile)); + assertTrue(reloaded.getModelFiles().containsKey(modelFile.toURI())); + assertEquals(modelContents, reloaded.getModelFiles().get(modelFile.toURI())); + } + + @Test + public void reloadsProjectWithChangesToFile() { + File root = getTestProjectRoot(); + String modelFilename = "main.smithy"; + String modelContents = "$version: \"2\"\nnamespace com.foo\nstructure Foo {}\n"; + File modelFile = createModelFileInModelsDir(root, modelFilename, modelContents); + SmithyProject project = SmithyProject.forDirectory(root); + + String updatedModelContents = modelContents + "structure Bar {}\n"; + SmithyProject reloaded = project.reloadWithChanges(modelFile.toURI(), updatedModelContents); + + assertFalse(reloaded.isBroken()); + assertFalse(reloaded.getModel().isBroken()); + assertEquals(root, reloaded.getRoot()); + assertEquals(1, reloaded.getSmithyFiles().size()); + assertTrue(reloaded.getSmithyFiles().contains(modelFile)); + assertTrue(reloaded.getModelFiles().containsKey(modelFile.toURI())); + assertEquals(updatedModelContents, reloaded.getModelFiles().get(modelFile.toURI())); + } + + @Test + public void reloadsProjectWithNewFile() { + File root = getTestProjectRoot(); + String modelFilename = "main.smithy"; + String modelContents = "$version: \"2\"\nnamespace com.foo\nstructure Foo {}\n"; + File modelFile = createModelFileInModelsDir(root, modelFilename, modelContents); + SmithyProject project = SmithyProject.forDirectory(root); + + String newModelFilename = "other.smithy"; + String newModelContents = "$version: \"2\"\nnamespace com.foo\nstructure Bar {}\n"; + File newModelFile = createModelFileInModelsDir(root, newModelFilename, newModelContents); + SmithyProject reloaded = project.reloadWithChanges(newModelFile.toURI(), newModelContents); + + assertFalse(reloaded.isBroken()); + assertFalse(reloaded.getModel().isBroken()); + assertEquals(root, reloaded.getRoot()); + assertEquals(2, reloaded.getSmithyFiles().size()); + assertTrue(reloaded.getSmithyFiles().contains(modelFile)); + assertTrue(reloaded.getSmithyFiles().contains(newModelFile)); + assertTrue(reloaded.getModelFiles().containsKey(modelFile.toURI())); + assertTrue(reloaded.getModelFiles().containsKey(newModelFile.toURI())); + assertEquals(modelContents, reloaded.getModelFiles().get(modelFile.toURI())); + assertEquals(newModelContents, reloaded.getModelFiles().get(newModelFile.toURI())); + } + + @Test + public void loadingProjectWithBrokenModel() { + File root = getTestProjectRoot(); + String modelFilename = "main.smithy"; + // Model has unknown shape + String modelContents = "$version: \"2\"\nnamespace com.foo\nstructure Foo { bar: Bar }\n"; + File modelFile = createModelFileInModelsDir(root, modelFilename, modelContents); + SmithyProject project = SmithyProject.forDirectory(root); + + assertFalse(project.isBroken()); + assertTrue(project.getModel().isBroken()); + assertEquals(project.getRoot(), root); + assertEquals(1, project.getSmithyFiles().size()); + assertTrue(project.getSmithyFiles().contains(modelFile)); + assertTrue(project.getModelFiles().containsKey(modelFile.toURI())); + assertEquals(modelContents, project.getModelFiles().get(modelFile.toURI())); + } + + @Test + public void reloadingProjectWithBrokenModelWithFixes() { + File root = getTestProjectRoot(); + String modelFilename = "main.smithy"; + // Model has unknown shape + String modelContents = "$version: \"2\"\nnamespace com.foo\nstructure Foo { bar: Bar }\n"; + File modelFile = createModelFileInModelsDir(root, modelFilename, modelContents); + SmithyProject project = SmithyProject.forDirectory(root); + + assertTrue(project.getModel().isBroken()); + + String updatedModelContents = modelContents + "structure Bar {}\n"; + SmithyProject reloaded = project.reloadWithChanges(modelFile.toURI(), updatedModelContents); + + assertFalse(reloaded.isBroken()); + assertFalse(reloaded.getModel().isBroken()); + assertEquals(root, reloaded.getRoot()); + assertEquals(1, reloaded.getSmithyFiles().size()); + assertTrue(reloaded.getSmithyFiles().contains(modelFile)); + assertTrue(reloaded.getModelFiles().containsKey(modelFile.toURI())); + assertEquals(updatedModelContents, reloaded.getModelFiles().get(modelFile.toURI())); + } + + @Test + public void loadingProjectWithBrokenConfig() { + File root = getTestProjectRoot(); + String invalidSmithyBuildContents = "{"; + File smithyBuildFile = Paths.get(root.getAbsolutePath(), "smithy-build.json").toFile(); + writeToFile(smithyBuildFile, invalidSmithyBuildContents); + SmithyProject project = SmithyProject.forDirectory(root); + + assertTrue(project.isBroken()); + assertEquals(1, project.getErrors().size()); + assertTrue(project.getErrors().get(0).contains("Error parsing JSON")); + } + + @Test + public void loadingProjectWithConfig() throws Exception { + Path jarImportModelPath = Paths.get(getClass().getResource("models/jars/smithy-test-traits.jar").toURI()); + String coordinates = "com.example:smithy-test-traits:0.0.1"; + DependencyResolver dependencyResolver = new MockDependencyResolver( + ResolvedArtifact.fromCoordinates(jarImportModelPath, coordinates) + ); + MavenConfig mavenConfig = MavenConfig.builder() + .dependencies(ListUtils.of(coordinates)) + .build(); + SmithyBuildExtensions buildExtensions = SmithyBuildExtensions.builder() + .maven(mavenConfig) + .build(); + + File root = getTestProjectRoot(); + String modelFilename = "main.smithy"; + String modelContents = "$version: \"2\"\nnamespace com.foo\nuse smithy.test#test\n@test()service Weather {}\n"; + File modelFile = createModelFileInModelsDir(root, modelFilename, modelContents); + + SmithyProject project = SmithyProject.load(buildExtensions, root, dependencyResolver); + + assertFalse(project.isBroken()); + assertFalse(project.getModel().isBroken()); + assertEquals(root, project.getRoot()); + assertEquals(1, project.getSmithyFiles().size()); + assertTrue(project.getSmithyFiles().contains(modelFile)); + assertEquals(1, project.getExternalJars().size()); + assertTrue(project.getExternalJars().contains(jarImportModelPath.toFile())); + assertEquals(3, project.getModelFiles().size()); + } + + private static File getTestProjectRoot() { + try { + File root = Files.createTempDirectory("test").toFile(); + root.deleteOnExit(); + return root; + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private static File createModelFileInModelsDir(File root, String filename, String contents) { + File modelRoot = Paths.get(root.getAbsolutePath(), "model").toFile(); + if (!modelRoot.exists()) { + modelRoot.mkdirs(); + } + File modelFile = Paths.get(modelRoot.getAbsolutePath(), filename).toFile(); + writeToFile(modelFile, contents); + return modelFile; + } + + private static void writeToFile(File file, String contents) { + try { + try (FileWriter fw = new FileWriter(file)) { + fw.write(contents); + fw.flush(); + } + } catch (Exception e) { + throw new RuntimeException(e); + } + } + private void correctLocation(Map locationMap, String shapeId, int startLine, int startColumn, int endLine, int endColumn) { Location location = locationMap.get(ShapeId.from(shapeId));