From fe8e4db10b148b6bdab4d809b5bf03137691a902 Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra Date: Wed, 22 Jan 2020 15:49:50 +0100 Subject: [PATCH] added diff endpoint which returns a parsed diff as json --- .../main/java/sonia/scm/web/VndMediaType.java | 1 + .../ui-components/src/repos/LoadingDiff.tsx | 13 +- .../src/repos/TokenizedDiffView.tsx | 9 +- .../src/repos/changesets/ChangesetDiff.tsx | 9 +- .../DefaultChangesetToChangesetDtoMapper.java | 17 +- .../scm/api/v2/resources/DiffResultDto.java | 64 +++++++ .../DiffResultToDiffResultDtoMapper.java | 132 ++++++++++++++ .../api/v2/resources/DiffRootResource.java | 20 ++ .../scm/api/v2/resources/ResourceLinks.java | 4 + .../DiffResultToDiffResultDtoMapperTest.java | 171 ++++++++++++++++++ 10 files changed, 424 insertions(+), 16 deletions(-) create mode 100644 scm-webapp/src/main/java/sonia/scm/api/v2/resources/DiffResultDto.java create mode 100644 scm-webapp/src/main/java/sonia/scm/api/v2/resources/DiffResultToDiffResultDtoMapper.java create mode 100644 scm-webapp/src/test/java/sonia/scm/api/v2/resources/DiffResultToDiffResultDtoMapperTest.java diff --git a/scm-core/src/main/java/sonia/scm/web/VndMediaType.java b/scm-core/src/main/java/sonia/scm/web/VndMediaType.java index 24250b26ba..f4a3d55878 100644 --- a/scm-core/src/main/java/sonia/scm/web/VndMediaType.java +++ b/scm-core/src/main/java/sonia/scm/web/VndMediaType.java @@ -29,6 +29,7 @@ public class VndMediaType { public static final String BRANCH = PREFIX + "branch" + SUFFIX; public static final String BRANCH_REQUEST = PREFIX + "branchRequest" + SUFFIX; public static final String DIFF = PLAIN_TEXT_PREFIX + "diff" + PLAIN_TEXT_SUFFIX; + public static final String DIFF_PARSED = PREFIX + "diffParsed" + SUFFIX;; public static final String USER_COLLECTION = PREFIX + "userCollection" + SUFFIX; public static final String GROUP_COLLECTION = PREFIX + "groupCollection" + SUFFIX; public static final String REPOSITORY_COLLECTION = PREFIX + "repositoryCollection" + SUFFIX; diff --git a/scm-ui/ui-components/src/repos/LoadingDiff.tsx b/scm-ui/ui-components/src/repos/LoadingDiff.tsx index a0a64a2903..e08a98c8a2 100644 --- a/scm-ui/ui-components/src/repos/LoadingDiff.tsx +++ b/scm-ui/ui-components/src/repos/LoadingDiff.tsx @@ -50,10 +50,15 @@ class LoadingDiff extends React.Component { this.setState({ loading: true }); apiClient .get(url) - .then(response => response.text()) - .then(parser.parse) - // $FlowFixMe - .then((diff: any) => { + .then(response => { + const contentType = response.headers.get("Content-Type"); + if (contentType && contentType.toLowerCase() === "application/vnd.scmm-diffparsed+json;v=2") { + return response.json().then(data => data.files); + } else { + return response.text().then(parser.parse); + } + }) + .then((diff: File[]) => { this.setState({ loading: false, diff: diff diff --git a/scm-ui/ui-components/src/repos/TokenizedDiffView.tsx b/scm-ui/ui-components/src/repos/TokenizedDiffView.tsx index fcc3397cb8..4b311a1fd9 100644 --- a/scm-ui/ui-components/src/repos/TokenizedDiffView.tsx +++ b/scm-ui/ui-components/src/repos/TokenizedDiffView.tsx @@ -46,10 +46,17 @@ type Props = { className?: string; }; +const determineLanguage = (file: File) => { + if (file.language) { + return file.language.toLowerCase(); + } + return "text"; +}; + const TokenizedDiffView: FC = ({ file, viewType, className, children }) => { const { tokens } = useTokenizeWorker(tokenize, { hunks: file.hunks, - language: file.language || "text" + language: determineLanguage(file) }); return ( diff --git a/scm-ui/ui-components/src/repos/changesets/ChangesetDiff.tsx b/scm-ui/ui-components/src/repos/changesets/ChangesetDiff.tsx index 1789cb5517..c89658ce57 100644 --- a/scm-ui/ui-components/src/repos/changesets/ChangesetDiff.tsx +++ b/scm-ui/ui-components/src/repos/changesets/ChangesetDiff.tsx @@ -11,13 +11,14 @@ type Props = WithTranslation & { class ChangesetDiff extends React.Component { isDiffSupported(changeset: Changeset) { - return !!changeset._links.diff; + return changeset._links.diff || !!changeset._links.diffParsed; } createUrl(changeset: Changeset) { - if (changeset._links.diff) { - const link = changeset._links.diff as Link; - return link.href + "?format=GIT"; + if (changeset._links.diffParsed) { + return (changeset._links.diffParsed as Link).href; + } else if (changeset._links.diff) { + return (changeset._links.diff as Link).href + "?format=GIT"; } throw new Error("diff link is missing"); } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/DefaultChangesetToChangesetDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/DefaultChangesetToChangesetDtoMapper.java index 1ae67c4282..868ed2acbc 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/DefaultChangesetToChangesetDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/DefaultChangesetToChangesetDtoMapper.java @@ -53,6 +53,12 @@ public abstract class DefaultChangesetToChangesetDtoMapper extends HalAppenderMa Embedded.Builder embeddedBuilder = embeddedBuilder(); + Links.Builder linksBuilder = linkingTo() + .self(resourceLinks.changeset().self(repository.getNamespace(), repository.getName(), source.getId())) + .single(link("diff", resourceLinks.diff().self(namespace, name, source.getId()))) + .single(link("sources", resourceLinks.source().self(namespace, name, source.getId()))) + .single(link("modifications", resourceLinks.modifications().self(namespace, name, source.getId()))); + try (RepositoryService repositoryService = serviceFactory.create(repository)) { if (repositoryService.isSupported(Command.TAGS)) { embeddedBuilder.with("tags", tagCollectionToDtoMapper.getTagDtoList(namespace, name, @@ -62,16 +68,13 @@ public abstract class DefaultChangesetToChangesetDtoMapper extends HalAppenderMa embeddedBuilder.with("branches", branchCollectionToDtoMapper.getBranchDtoList(namespace, name, getListOfObjects(source.getBranches(), branchName -> Branch.normalBranch(branchName, source.getId())))); } + + if (repositoryService.isSupported(Command.DIFF_RESULT)) { + linksBuilder.single(link("diffParsed", resourceLinks.diff().parsed(namespace, name, source.getId()))); + } } embeddedBuilder.with("parents", getListOfObjects(source.getParents(), parent -> changesetToParentDtoMapper.map(new Changeset(parent, 0L, null), repository))); - Links.Builder linksBuilder = linkingTo() - .self(resourceLinks.changeset().self(repository.getNamespace(), repository.getName(), source.getId())) - .single(link("diff", resourceLinks.diff().self(namespace, name, source.getId()))) - .single(link("sources", resourceLinks.source().self(namespace, name, source.getId()))) - .single(link("modifications", resourceLinks.modifications().self(namespace, name, source.getId()))); - - applyEnrichers(new EdisonHalAppender(linksBuilder, embeddedBuilder), source, repository); return new ChangesetDto(linksBuilder.build(), embeddedBuilder.build()); diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/DiffResultDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/DiffResultDto.java new file mode 100644 index 0000000000..161e7d8a75 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/DiffResultDto.java @@ -0,0 +1,64 @@ +package sonia.scm.api.v2.resources; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import de.otto.edison.hal.HalRepresentation; +import lombok.Data; + +import java.util.List; + +@Data +public class DiffResultDto extends HalRepresentation { + + private List files; + + @Data + @JsonInclude(JsonInclude.Include.NON_DEFAULT) + public static class FileDto { + + private String oldPath; + private String newPath; + private boolean oldEndingNewLine; + private boolean newEndingNewLine; + private String oldRevision; + private String newRevision; + private String newMode; + private String oldMode; + private String type; + private String language; + private List hunks; + + } + + @Data + @JsonInclude(JsonInclude.Include.NON_DEFAULT) + public static class HunkDto { + + private String content; + private int oldStart; + private int newStart; + private int oldLines; + private int newLines; + private List changes; + + } + + @Data + @JsonInclude(JsonInclude.Include.NON_DEFAULT) + public static class ChangeDto { + + private String content; + private String type; + @JsonProperty("isNormal") + private boolean isNormal; + @JsonProperty("isInsert") + private boolean isInsert; + @JsonProperty("isDelete") + private boolean isDelete; + private int lineNumber; + private int oldLineNumber; + private int newLineNumber; + + } + +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/DiffResultToDiffResultDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/DiffResultToDiffResultDtoMapper.java new file mode 100644 index 0000000000..d9abde23c3 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/DiffResultToDiffResultDtoMapper.java @@ -0,0 +1,132 @@ +package sonia.scm.api.v2.resources; + +import com.github.sdorra.spotter.ContentTypes; +import com.github.sdorra.spotter.Language; +import com.google.common.base.Strings; +import sonia.scm.repository.api.DiffFile; +import sonia.scm.repository.api.DiffLine; +import sonia.scm.repository.api.DiffResult; +import sonia.scm.repository.api.Hunk; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.OptionalInt; + +/** + * TODO conflicts, copy and rename + */ +final class DiffResultToDiffResultDtoMapper { + + static final DiffResultToDiffResultDtoMapper INSTANCE = new DiffResultToDiffResultDtoMapper(); + + private DiffResultToDiffResultDtoMapper() { + } + + public DiffResultDto map(DiffResult result) { + List files = new ArrayList<>(); + for (DiffFile file : result) { + files.add(mapFile(file)); + } + DiffResultDto dto = new DiffResultDto(); + dto.setFiles(files); + return dto; + } + + private DiffResultDto.FileDto mapFile(DiffFile file) { + DiffResultDto.FileDto dto = new DiffResultDto.FileDto(); + // ??? + dto.setOldEndingNewLine(true); + dto.setNewEndingNewLine(true); + + String newPath = file.getNewPath(); + String oldPath = file.getOldPath(); + + String path; + if (isFilePath(newPath) && isFileNull(oldPath)) { + path = newPath; + dto.setType("add"); + } else if (isFileNull(newPath) && isFilePath(oldPath)) { + path = oldPath; + dto.setType("delete"); + } else if (isFilePath(newPath) && isFilePath(oldPath)) { + path = newPath; + dto.setType("modify"); + } else { + // TODO copy and rename? + throw new IllegalStateException("no file without path"); + } + + dto.setNewPath(newPath); + dto.setNewRevision(file.getNewRevision()); + + dto.setOldPath(oldPath); + dto.setOldRevision(file.getOldRevision()); + + + Optional language = ContentTypes.detect(path).getLanguage(); + language.ifPresent(value -> dto.setLanguage(value.getName())); + + List hunks = new ArrayList<>(); + for (Hunk hunk : file) { + hunks.add(mapHunk(hunk)); + } + dto.setHunks(hunks); + + return dto; + } + + private boolean isFilePath(String path) { + return !isFileNull(path); + } + + private boolean isFileNull(String path) { + return Strings.isNullOrEmpty(path) || "/dev/null".equals(path); + } + + private DiffResultDto.HunkDto mapHunk(Hunk hunk) { + DiffResultDto.HunkDto dto = new DiffResultDto.HunkDto(); + dto.setContent(hunk.getRawHeader()); + + dto.setNewStart(hunk.getNewStart()); + dto.setNewLines(hunk.getNewLineCount()); + + dto.setOldStart(hunk.getOldStart()); + dto.setOldLines(hunk.getOldLineCount()); + + List changes = new ArrayList<>(); + for (DiffLine line : hunk) { + changes.add(mapLine(line)); + } + + dto.setChanges(changes); + return dto; + } + + private DiffResultDto.ChangeDto mapLine(DiffLine line) { + DiffResultDto.ChangeDto dto = new DiffResultDto.ChangeDto(); + dto.setContent(line.getContent()); + + OptionalInt newLineNumber = line.getNewLineNumber(); + OptionalInt oldLineNumber = line.getOldLineNumber(); + if (newLineNumber.isPresent() && !oldLineNumber.isPresent()) { + dto.setType("insert"); + dto.setInsert(true); + dto.setLineNumber(newLineNumber.getAsInt()); + } else if (!newLineNumber.isPresent() && oldLineNumber.isPresent()) { + dto.setType("delete"); + dto.setDelete(true); + dto.setLineNumber(oldLineNumber.getAsInt()); + } else if (newLineNumber.isPresent() && oldLineNumber.isPresent()) { + dto.setType("normal"); + dto.setNormal(true); + dto.setNewLineNumber(newLineNumber.getAsInt()); + dto.setOldLineNumber(oldLineNumber.getAsInt()); + } else { + throw new IllegalStateException("line without line number"); + } + + return dto; + } + +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/DiffRootResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/DiffRootResource.java index 016c316500..6e210f1372 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/DiffRootResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/DiffRootResource.java @@ -6,6 +6,7 @@ import sonia.scm.NotFoundException; import sonia.scm.repository.NamespaceAndName; import sonia.scm.repository.api.DiffCommandBuilder; import sonia.scm.repository.api.DiffFormat; +import sonia.scm.repository.api.DiffResult; import sonia.scm.repository.api.RepositoryService; import sonia.scm.repository.api.RepositoryServiceFactory; import sonia.scm.util.HttpUtil; @@ -70,4 +71,23 @@ public class DiffRootResource { .build(); } } + + @GET + @Path("{revision}.json") + @Produces(VndMediaType.DIFF_PARSED) + @StatusCodes({ + @ResponseCode(code = 200, condition = "success"), + @ResponseCode(code = 400, condition = "Bad Request"), + @ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), + @ResponseCode(code = 403, condition = "not authorized, the current user has no privileges to read the diff"), + @ResponseCode(code = 404, condition = "not found, no revision with the specified param for the repository available or repository not found"), + @ResponseCode(code = 500, condition = "internal server error") + }) + public Response getParsed(@PathParam("namespace") String namespace, @PathParam("name") String name, @PathParam("revision") String revision) throws IOException { + HttpUtil.checkForCRLFInjection(revision); + try (RepositoryService repositoryService = serviceFactory.create(new NamespaceAndName(namespace, name))) { + DiffResult diffResult = repositoryService.getDiffResultCommand().setRevision(revision).getDiffResult(); + return Response.ok(DiffResultToDiffResultDtoMapper.INSTANCE.map(diffResult)).build(); + } + } } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java index 8434a833d7..f4921a5c85 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java @@ -362,6 +362,10 @@ class ResourceLinks { return diffLinkBuilder.method("getRepositoryResource").parameters(namespace, name).method("diff").parameters().method("get").parameters(id).href(); } + String parsed(String namespace, String name, String id) { + return diffLinkBuilder.method("getRepositoryResource").parameters(namespace, name).method("diff").parameters().method("getParsed").parameters(id).href(); + } + String all(String namespace, String name) { return diffLinkBuilder.method("getRepositoryResource").parameters(namespace, name).method("diff").parameters().method("getAll").parameters().href(); } diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/DiffResultToDiffResultDtoMapperTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/DiffResultToDiffResultDtoMapperTest.java new file mode 100644 index 0000000000..2b75e06f39 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/DiffResultToDiffResultDtoMapperTest.java @@ -0,0 +1,171 @@ +package sonia.scm.api.v2.resources; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.repository.api.DiffFile; +import sonia.scm.repository.api.DiffLine; +import sonia.scm.repository.api.DiffResult; +import sonia.scm.repository.api.Hunk; + +import java.util.Arrays; +import java.util.List; +import java.util.OptionalInt; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class DiffResultToDiffResultDtoMapperTest { + + @Test + void shouldMapDiffResult() { + DiffResult result = result( + addedFile("A.java", "abc"), + modifiedFile("B.tsx", "def", "abc", + hunk("@@ -3,4 1,2 @@", 1, 2, 3, 4, + insertedLine("a", 1), + modifiedLine("b", 2), + deletedLine("c", 3) + ) + ), + deletedFile("C.go", "ghi") + ); + + DiffResultDto dto = DiffResultToDiffResultDtoMapper.INSTANCE.map(result); + + List files = dto.getFiles(); + assertAddedFile(files.get(0), "A.java", "abc", "Java"); + assertModifiedFile(files.get(1), "B.tsx", "abc", "def", "TypeScript"); + assertDeletedFile(files.get(2), "C.go", "ghi", "Go"); + + DiffResultDto.HunkDto hunk = files.get(1).getHunks().get(0); + assertHunk(hunk, "@@ -3,4 1,2 @@", 1, 2, 3, 4); + + List changes = hunk.getChanges(); + assertInsertedLine(changes.get(0), "a", 1); + assertModifiedLine(changes.get(1), "b", 2); + assertDeletedLine(changes.get(2), "c", 3); + } + + public void assertInsertedLine(DiffResultDto.ChangeDto change, String content, int lineNumber) { + assertThat(change.getContent()).isEqualTo(content); + assertThat(change.getLineNumber()).isEqualTo(lineNumber); + assertThat(change.getType()).isEqualTo("insert"); + assertThat(change.isInsert()).isTrue(); + } + + private void assertModifiedLine(DiffResultDto.ChangeDto change, String content, int lineNumber) { + assertThat(change.getContent()).isEqualTo(content); + assertThat(change.getNewLineNumber()).isEqualTo(lineNumber); + assertThat(change.getOldLineNumber()).isEqualTo(lineNumber); + assertThat(change.getType()).isEqualTo("normal"); + assertThat(change.isNormal()).isTrue(); + } + + private void assertDeletedLine(DiffResultDto.ChangeDto change, String content, int lineNumber) { + assertThat(change.getContent()).isEqualTo(content); + assertThat(change.getLineNumber()).isEqualTo(lineNumber); + assertThat(change.getType()).isEqualTo("delete"); + assertThat(change.isDelete()).isTrue(); + } + + private void assertHunk(DiffResultDto.HunkDto hunk, String content, int newStart, int newLineCount, int oldStart, int oldLineCount) { + assertThat(hunk.getContent()).isEqualTo(content); + assertThat(hunk.getNewStart()).isEqualTo(newStart); + assertThat(hunk.getNewLines()).isEqualTo(newLineCount); + assertThat(hunk.getOldStart()).isEqualTo(oldStart); + assertThat(hunk.getOldLines()).isEqualTo(oldLineCount); + } + + private void assertAddedFile(DiffResultDto.FileDto file, String path, String revision, String language) { + assertThat(file.getNewPath()).isEqualTo(path); + assertThat(file.getNewRevision()).isEqualTo(revision); + assertThat(file.getType()).isEqualTo("add"); + assertThat(file.getLanguage()).isEqualTo(language); + } + + private void assertModifiedFile(DiffResultDto.FileDto file, String path, String oldRevision, String newRevision, String language) { + assertThat(file.getNewPath()).isEqualTo(path); + assertThat(file.getNewRevision()).isEqualTo(newRevision); + assertThat(file.getOldPath()).isEqualTo(path); + assertThat(file.getOldRevision()).isEqualTo(oldRevision); + assertThat(file.getType()).isEqualTo("modify"); + assertThat(file.getLanguage()).isEqualTo(language); + } + + private void assertDeletedFile(DiffResultDto.FileDto file, String path, String revision, String language) { + assertThat(file.getOldPath()).isEqualTo(path); + assertThat(file.getOldRevision()).isEqualTo(revision); + assertThat(file.getType()).isEqualTo("delete"); + assertThat(file.getLanguage()).isEqualTo(language); + } + + private DiffResult result(DiffFile... files) { + DiffResult result = mock(DiffResult.class); + when(result.iterator()).thenReturn(Arrays.asList(files).iterator()); + return result; + } + + private DiffFile addedFile(String path, String revision, Hunk... hunks) { + DiffFile file = mock(DiffFile.class); + when(file.getNewPath()).thenReturn(path); + when(file.getNewRevision()).thenReturn(revision); + when(file.iterator()).thenReturn(Arrays.asList(hunks).iterator()); + return file; + } + + private DiffFile deletedFile(String path, String revision, Hunk... hunks) { + DiffFile file = mock(DiffFile.class); + when(file.getOldPath()).thenReturn(path); + when(file.getOldRevision()).thenReturn(revision); + when(file.iterator()).thenReturn(Arrays.asList(hunks).iterator()); + return file; + } + + private DiffFile modifiedFile(String path, String newRevision, String oldRevision, Hunk... hunks) { + DiffFile file = mock(DiffFile.class); + when(file.getNewPath()).thenReturn(path); + when(file.getNewRevision()).thenReturn(newRevision); + when(file.getOldPath()).thenReturn(path); + when(file.getOldRevision()).thenReturn(oldRevision); + when(file.iterator()).thenReturn(Arrays.asList(hunks).iterator()); + return file; + } + + private Hunk hunk(String rawHeader, int newStart, int newLineCount, int oldStart, int oldLineCount, DiffLine... lines) { + Hunk hunk = mock(Hunk.class); + when(hunk.getRawHeader()).thenReturn(rawHeader); + when(hunk.getNewStart()).thenReturn(newStart); + when(hunk.getNewLineCount()).thenReturn(newLineCount); + when(hunk.getOldStart()).thenReturn(oldStart); + when(hunk.getOldLineCount()).thenReturn(oldLineCount); + when(hunk.iterator()).thenReturn(Arrays.asList(lines).iterator()); + return hunk; + } + + private DiffLine insertedLine(String content, int lineNumber) { + DiffLine line = mock(DiffLine.class); + when(line.getContent()).thenReturn(content); + when(line.getNewLineNumber()).thenReturn(OptionalInt.of(lineNumber)); + when(line.getOldLineNumber()).thenReturn(OptionalInt.empty()); + return line; + } + + private DiffLine modifiedLine(String content, int lineNumber) { + DiffLine line = mock(DiffLine.class); + when(line.getContent()).thenReturn(content); + when(line.getNewLineNumber()).thenReturn(OptionalInt.of(lineNumber)); + when(line.getOldLineNumber()).thenReturn(OptionalInt.of(lineNumber)); + return line; + } + + private DiffLine deletedLine(String content, int lineNumber) { + DiffLine line = mock(DiffLine.class); + when(line.getContent()).thenReturn(content); + when(line.getNewLineNumber()).thenReturn(OptionalInt.empty()); + when(line.getOldLineNumber()).thenReturn(OptionalInt.of(lineNumber)); + return line; + } +}