Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Determine end of definition location #35

Merged
merged 9 commits into from
Apr 29, 2022
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,5 @@ bin

.java-version
*.smithy
!/src/test/resources/**/*.smithy
.ammonite
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@
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.neighbor.Walker;
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.ValidationEvent;

Expand Down Expand Up @@ -202,7 +206,11 @@ private List<String> textBufferContents(String path) throws IOException {
LspLog.println("Path " + path + " was found in temporary buffer");
contents = Arrays.stream(tempContents.split("\n")).collect(Collectors.toList());
} else {
contents = readAll(new File(URI.create(path)));
try {
contents = readAll(new File(URI.create(path)));
} catch (IllegalArgumentException e) {
contents = readAll(new File(path));
}
}

}
Expand Down Expand Up @@ -254,8 +262,30 @@ private String findToken(String path, Position p) throws IOException {
public CompletableFuture<Either<List<? extends Location>, List<? extends LocationLink>>> definition(
DefinitionParams params) {
try {
List<Location> locations;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be nice to have a description of what this is doing behaviorally.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added with an example.

Optional<ShapeId> initialShapeId = project.getShapeIdFromLocation(params.getTextDocument().getUri(),
params.getPosition());
String found = findToken(params.getTextDocument().getUri(), params.getPosition());
return Utils.completableFuture(Either.forLeft(project.getLocations().getOrDefault(found, noLocations)));
if (initialShapeId.isPresent()) {
Model model = project.getModel().unwrap();
Shape initialShape = model.getShape(initialShapeId.get()).get();
// Find first neighbor (non-member) with name that matches token.
Walker shapeWalker = new Walker(NeighborProviderIndex.of(model).getProvider());
Optional<ShapeId> target = shapeWalker.walkShapes(initialShape).stream()
.filter(shape -> !shape.isMemberShape())
.map(shape -> shape.getId())
.filter(shape -> shape.getName().equals(found))
.findFirst();
// Use location on target, or else default to initial shape.
locations = Collections.singletonList(project.getLocations().get(target.orElse(initialShapeId.get())));
} else {
// If cursor location doesn't correspond to definition, return locations of all shapes that match token.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure what this comment means.

locations = project.getLocations().entrySet().stream()
.filter(entry -> entry.getKey().getName().equals(found))
.map(entry -> entry.getValue())
.collect(Collectors.toList());
}
return Utils.completableFuture(Either.forLeft(locations));
} catch (Exception e) {
// TODO: handle exception

Expand Down Expand Up @@ -330,7 +360,11 @@ private File fileUri(TextDocumentItem tdi) {
}

private File fileFromUri(String uri) {
return new File(URI.create(uri));
try {
return new File(URI.create(uri));
} catch (IllegalArgumentException e) {
return new File(uri);
}
}

/**
Expand Down
217 changes: 182 additions & 35 deletions src/main/java/software/amazon/smithy/lsp/ext/SmithyProject.java
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,19 @@

import java.io.File;
import java.io.IOException;
import java.io.Serializable;
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.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.eclipse.lsp4j.Location;
Expand All @@ -35,17 +39,21 @@
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.FromSourceLocation;
import software.amazon.smithy.model.Model;
import software.amazon.smithy.model.SourceLocation;
import software.amazon.smithy.model.shapes.Shape;
import software.amazon.smithy.model.shapes.ShapeId;
import software.amazon.smithy.model.shapes.ShapeType;
import software.amazon.smithy.model.traits.Trait;
import software.amazon.smithy.model.validation.ValidatedResult;

public final class SmithyProject {
private final List<Path> imports;
private final List<File> smithyFiles;
private final List<File> externalJars;
private Map<String, List<Location>> locations = Collections.emptyMap();
private ValidatedResult<Model> model;
private Map<ShapeId, Location> locations = Collections.emptyMap();
private final ValidatedResult<Model> model;
private final File root;

private SmithyProject(List<Path> imports, List<File> smithyFiles, List<File> externalJars, File root,
Expand All @@ -55,9 +63,7 @@ private SmithyProject(List<Path> imports, List<File> smithyFiles, List<File> ext
this.model = model;
this.smithyFiles = smithyFiles;
this.externalJars = externalJars;
model.getResult().ifPresent(m -> {
this.locations = collectLocations(m);
});
model.getResult().ifPresent(m -> this.locations = collectLocations(m));
}

/**
Expand All @@ -71,7 +77,7 @@ private SmithyProject(List<Path> imports, List<File> smithyFiles, List<File> ext
* @return either an error, or a loaded project
*/
public Either<Exception, SmithyProject> recompile(File changed, File exclude) {
List<File> newFiles = new ArrayList<File>();
List<File> newFiles = new ArrayList<>();

for (File existing : onlyExistingFiles(this.smithyFiles)) {
if (exclude != null && !existing.equals(exclude)) {
Expand Down Expand Up @@ -102,14 +108,10 @@ public List<SmithyCompletionItem> getCompletions(String token) {
return this.model.getResult().map(model -> Completions.find(model, token)).orElse(Collections.emptyList());
}

public Map<String, List<Location>> getLocations() {
public Map<ShapeId, Location> getLocations() {
return this.locations;
}

public Either<Exception, SmithyProject> reload(SmithyBuildExtensions config) {
return load(config, this.root);
}

/**
* Load the project using a SmithyBuildExtensions configuration and workspace
* root.
Expand Down Expand Up @@ -145,7 +147,7 @@ private static Either<Exception, SmithyProject> load(List<Path> imports, List<Fi
if (model.isLeft()) {
return Either.forLeft(model.getLeft());
} else {
model.getRight().getValidationEvents().forEach(event -> LspLog.println(event));
model.getRight().getValidationEvents().forEach(LspLog::println);
return Either.forRight(new SmithyProject(imports, smithyFiles, externalJars, root, model.getRight()));
}
}
Expand All @@ -159,37 +161,162 @@ public File getRoot() {
return this.root;
}

private static Map<String, List<Location>> collectLocations(Model model) {
Map<String, List<Location>> locations = new HashMap<>();
model.shapes().forEach(shape -> {
SourceLocation sourceLocation = shape.getSourceLocation();
String fileName = sourceLocation.getFilename();
String uri = Utils.isJarFile(fileName)
? Utils.toSmithyJarFile(fileName)
: !fileName.startsWith("file:") ? "file:" + fileName
: fileName;
private static Map<ShapeId, Location> collectLocations(Model model) {
Map<ShapeId, Location> locations = new HashMap<>();
List<String> modelFiles = model.shapes()
.map(shape -> shape.getSourceLocation().getFilename())
.distinct()
.collect(Collectors.toList());
for (String modelFile : modelFiles) {
List<String> lines = getFileLines(modelFile);
int endMarker = getInitialEndMarker(lines);
int memberEndMarker = getInitialEndMarker(lines);

// Get shapes reverse-sorted by source location to work from bottom of file to top.
List<Shape> shapes = model.shapes()
.filter(shape -> shape.getSourceLocation().getFilename().equals(modelFile))
// TODO: Once the change in https://github.com/awslabs/smithy/pull/1192 lands, replace with with
// `.sorted(Comparator.comparing(Shape::getSourceLocation).reversed())`.
.sorted(new SourceLocationSorter().reversed())
.collect(Collectors.toList());

Position pos = new Position(sourceLocation.getLine() - 1, sourceLocation.getColumn() - 1);
Location location = new Location(uri, new Range(pos, pos));

String shapeName = shape.getId().getName();
// Members get the same shapeName as their parent structure
// so we ignore them, to avoil producing a location per-member
// TODO: index members somehow as well?
if (shape.getType() != ShapeType.MEMBER) {
if (locations.containsKey(shapeName)) {
locations.get(shapeName).add(location);
for (Shape shape : shapes) {
SourceLocation sourceLocation = shape.getSourceLocation();
Position startPosition = new Position(sourceLocation.getLine() - 1, sourceLocation.getColumn() - 1);
Position endPosition;
if (endMarker < sourceLocation.getLine()) {
endPosition = new Position(sourceLocation.getLine() - 1, sourceLocation.getColumn() - 1);
} else {
List<Location> locList = new ArrayList<Location>();
locList.add(location);
locations.put(shapeName, locList);
endPosition = getEndPosition(endMarker, lines);
}
}
});

// Find the end of a member's location by first trimming trailing commas, empty lines and closing
// structure braces.
if (shape.getType() == ShapeType.MEMBER) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it reasonable to break these branches up in to their own methods for readability?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I broke out the non-member branch since it only updates the end marker, but the non-member branch is responsible for updating two separate markers and the end position of a member shape. I think it made readability worse for separate method to do all of that.

int currentMemberEndMarker = memberEndMarker < endMarker ? memberEndMarker : endMarker;
String currentLine = lines.get(currentMemberEndMarker - 1).trim();
while (currentLine.startsWith("//") || currentLine.equals("") || currentLine.equals("}")) {
currentMemberEndMarker = currentMemberEndMarker - 1;
currentLine = lines.get(currentMemberEndMarker - 1).trim();
}
// Set the member's end position.
endPosition = getEndPosition(currentMemberEndMarker, lines);
// Advance the member end marker on any traits on the current member, so that the next member
// location starts in the right place.
List<Trait> traits = new ArrayList<>(shape.getAllTraits().values());
if (!traits.isEmpty()) {
traits.sort(new SourceLocationSorter());
currentMemberEndMarker = traits.get(0).getSourceLocation().getLine();
}
memberEndMarker = currentMemberEndMarker - 1;
} else {
// When handling non-member shapes, advance the end marker for traits and comments above the current
// shape.
endMarker = startPosition.getLine();
List<Trait> traits = new ArrayList<>(shape.getAllTraits().values());
// If the shape has traits, advance the end marker again.
if (!traits.isEmpty()) {
// TODO: Replace with Comparator when this class is removed.
traits.sort(new SourceLocationSorter());
endMarker = traits.get(0).getSourceLocation().getLine() - 1;
}
// Move the end marker when encountering line comments or empty lines.
if (lines.size() > endMarker) {
while (lines.get(endMarker - 1).trim().startsWith("//")
|| lines.get(endMarker - 1).trim().equals("")) {
endMarker = endMarker - 1;
}
}
}
Location location = new Location(getUri(modelFile), new Range(startPosition, endPosition));
locations.put(shape.getId(), location);
}
}
return locations;
}

/**
* Returns the shapeId of the shape that corresponds to the file uri and position within the model.
*
* @param uri String uri of model file
* @param position Cursor position within model file
* @return ShapeId of corresponding shape defined at location.
*/
public Optional<ShapeId> getShapeIdFromLocation(String uri, Position position) {
Comparator<Map.Entry<ShapeId, Location>> rangeSize = Comparator.comparing(entry ->
entry.getValue().getRange().getEnd().getLine() - entry.getValue().getRange().getStart().getLine());

List<Map.Entry<ShapeId, Location>> matchingShapes = locations.entrySet().stream()
.filter(entry -> entry.getValue().getUri().endsWith(Paths.get(uri).toString()))
.filter(filterByLocation(position))
// Since the position is in each of the overlapping shapes, return the smallest range.
.sorted(rangeSize)
.collect(Collectors.toList());
if (matchingShapes.isEmpty()) {
return Optional.empty();
}
return Optional.ofNullable(matchingShapes.get(0).getKey());
}

private Predicate<Map.Entry<ShapeId, Location>> filterByLocation(Position position) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems like it should be a Predicate<Location> instead, since it doesn't use the key at all.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree this looks a bit awkward, but the key (the shapeId) is what needs to be carried forward. If I mapped to a location first, I'd lose the reference to the shapeId. I did simplify this a bit by mapping to a shapeId in the stream after filtering.

return entry -> {
Range range = entry.getValue().getRange();
if (range.getStart().getLine() > position.getLine()) {
return false;
}
if (range.getEnd().getLine() < position.getLine()) {
return false;
}
if (range.getStart().getLine() == position.getLine()) {
return range.getStart().getCharacter() <= position.getCharacter();
} else if (range.getEnd().getLine() == position.getLine()) {
return range.getEnd().getCharacter() >= position.getCharacter();
}
return true;
};
}

private static int getInitialEndMarker(List<String> lines) {
int endMarker = lines.size();
// Remove empty lines from the end of the file.
if (lines.size() > 0) {
while (lines.get(endMarker - 1).trim().equals("")) {
endMarker = endMarker - 1;
}
}
return endMarker;
}

// If the lines of the model were successfully loaded, return the end position of the actual shape line,
// otherwise set it to the start of the next line.
private static Position getEndPosition(int endMarker, List<String> lines) {
if (lines.size() >= endMarker) {
return new Position(endMarker - 1, lines.get(endMarker - 1).length());
}
return new Position(endMarker, 0);
}

private static List<String> getFileLines(String file) {
try {
if (Utils.isSmithyJarFile(file) || Utils.isJarFile(file)) {
return Utils.jarFileContents(Utils.toSmithyJarFile(file));
} else {
return Files.readAllLines(Paths.get(file));
}
} catch (IOException e) {
LspLog.println("File " + file + " could not be loaded.");
}
return Collections.emptyList();
}

private static String getUri(String fileName) {
return Utils.isJarFile(fileName)
? Utils.toSmithyJarFile(fileName)
: !fileName.startsWith("file:") ? "file:" + fileName
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider breaking this nested ternary out for readability.

: fileName;
}

private static Boolean isValidSmithyFile(Path file) {
String fName = file.getFileName().toString();
return fName.endsWith(Constants.SMITHY_EXTENSION);
Expand Down Expand Up @@ -232,4 +359,24 @@ private static List<File> downloadExternalDependencies(SmithyBuildExtensions ext
private static List<File> onlyExistingFiles(Collection<File> files) {
return files.stream().filter(File::isFile).collect(Collectors.toList());
}

// TODO: Remove this Class once the change in https://github.com/awslabs/smithy/pull/1192 is available.
private static class SourceLocationSorter implements Comparator<FromSourceLocation>, Serializable {
@Override
public int compare(FromSourceLocation s1, FromSourceLocation s2) {
SourceLocation sourceLocation = s1.getSourceLocation();
SourceLocation otherSourceLocation = s2.getSourceLocation();

if (!sourceLocation.getFilename().equals(otherSourceLocation.getFilename())) {
return sourceLocation.getFilename().compareTo(otherSourceLocation.getFilename());
}

int lineComparison = Integer.compare(sourceLocation.getLine(), otherSourceLocation.getLine());
if (lineComparison != 0) {
return lineComparison;
}

return Integer.compare(sourceLocation.getColumn(), otherSourceLocation.getColumn());
}
}
}
Loading