diff options
author | HÃ¥kon Hallingstad <hakon@oath.com> | 2018-05-07 12:23:08 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2018-05-07 12:23:08 +0200 |
commit | c852de1e8352ff7323f2a4b2dac3cceb62561fb2 (patch) | |
tree | 5da82d20dc27652c6086af7fd30e9304f41d1f76 /node-admin/src | |
parent | e7d292dbf1525ae004a40365e2327379b6474e2b (diff) | |
parent | b5181039ad233c4c28820ee9217334bcddb70e3f (diff) |
Merge pull request #5783 from vespa-engine/hakonhall/procedural-editor
Procedural editor
Diffstat (limited to 'node-admin/src')
13 files changed, 1337 insertions, 0 deletions
diff --git a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/editor/Cursor.java b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/editor/Cursor.java new file mode 100644 index 00000000000..85e98052bba --- /dev/null +++ b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/editor/Cursor.java @@ -0,0 +1,96 @@ +// Copyright 2018 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. + +package com.yahoo.vespa.hosted.node.admin.task.util.editor; + +import java.util.Optional; +import java.util.function.Function; +import java.util.regex.Pattern; + +/** + * Simulates an editor cursor. + * + * @author hakon + */ +public interface Cursor { + // CURSOR AND BUFFER QUERIES + + String getBufferText(); + String getLine(); + String getPrefix(); + String getSuffix(); + String getTextTo(Mark mark); + + Position getPosition(); + Mark createMark(); + + // CURSOR MOVEMENT + + Cursor moveToStartOfBuffer(); + Cursor moveToEndOfBuffer(); + + Cursor moveToStartOfLine(); + Cursor moveToStartOfPreviousLine(); + Cursor moveToStartOfNextLine(); + Cursor moveToStartOf(int lineIndex); + + Cursor moveToEndOfLine(); + Cursor moveToEndOfPreviousLine(); + Cursor moveToEndOfNextLine(); + Cursor moveToEndOf(int lineIndex); + + Cursor moveForward(); + Cursor moveForward(int times); + Cursor moveBackward(); + Cursor moveBackward(int times); + + Cursor moveTo(Mark mark); + Cursor moveTo(Position position); + Cursor moveTo(int lineIndex, int columnIndex); + + Optional<Match> moveForwardToStartOfMatch(Pattern pattern); + Optional<Match> moveForwardToEndOfMatch(Pattern pattern); + + boolean skipBackward(String text); + boolean skipForward(String text); + + // BUFFER MODIFICATIONS + + Cursor write(String text); + Cursor writeLine(String line); + Cursor writeLines(String... lines); + Cursor writeLines(Iterable<String> lines); + + Cursor writeNewline(); + Cursor writeNewlineAfter(); + + Cursor deleteAll(); + Cursor deleteLine(); + Cursor deletePrefix(); + Cursor deleteSuffix(); + + Cursor deleteForward(); + Cursor deleteForward(int times); + Cursor deleteBackward(); + Cursor deleteBackward(int times); + + Cursor deleteTo(Mark mark); + + boolean replaceMatch(Pattern pattern, Function<Match, String> replacer); + + /** + * Replace matches of a pattern. + * + * <p>The search for {@code pattern} starts at cursor and matches against the remaining line, + * and the full line for the following lines. Each match is replaced by a String returned by + * {@code replacer::apply}. + * + * <p>The cursor is unchanged without any matches, or moved to the end of the last replacement. + * + * <p>To replace all matches in a buffer, first call {@link #moveToStartOfBuffer()} to + * postion the cursor at the beginning of the buffer. + * + * @see #moveForwardToStartOfMatch(Pattern) + * @see #moveForwardToEndOfMatch(Pattern) + */ + int replaceMatches(Pattern pattern, Function<Match, String> replacer); +} diff --git a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/editor/CursorImpl.java b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/editor/CursorImpl.java new file mode 100644 index 00000000000..47c710395f5 --- /dev/null +++ b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/editor/CursorImpl.java @@ -0,0 +1,357 @@ +// Copyright 2018 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. + +package com.yahoo.vespa.hosted.node.admin.task.util.editor; + +import java.util.Arrays; +import java.util.Optional; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.regex.Pattern; + +import static com.yahoo.collections.Comparables.max; +import static com.yahoo.collections.Comparables.min; + +/** + * @author hakon + */ +public class CursorImpl implements Cursor { + private final TextBuffer textBuffer; + private final Object unique = new Object(); + + private Position position; + + /** + * Creates a cursor to a text buffer. + * + * WARNING: The text buffer MUST NOT be accessed outside this cursor. This cursor + * takes sole ownership of the text buffer. + * + * @param textBuffer the text buffer this cursor owns and operates on + */ + CursorImpl(TextBuffer textBuffer) { + this.textBuffer = textBuffer; + position = textBuffer.getStartOfText(); + } + + @Override + public Position getPosition() { + return position; + } + + @Override + public Mark createMark() { + return new Mark(position, textBuffer.getVersion(), unique); + } + + @Override + public String getBufferText() { + return textBuffer.getString(); + } + + @Override + public String getLine() { + return textBuffer.getLine(position); + } + + @Override + public String getPrefix() { + return textBuffer.getLinePrefix(position); + } + + @Override + public String getSuffix() { + return textBuffer.getLineSuffix(position); + } + + @Override + public String getTextTo(Mark mark) { + validateMark(mark); + + Position start = min(mark.position(), position); + Position end = max(mark.position(), position); + + return textBuffer.getSubstring(start, end); + } + + @Override + public Cursor moveToStartOfBuffer() { + position = textBuffer.getStartOfText(); + return this; + } + + @Override + public Cursor moveToEndOfBuffer() { + position = textBuffer.getEndOfText(); + return this; + } + + @Override + public Cursor moveToStartOfLine() { + position = textBuffer.getStartOfLine(position); + return this; + } + + @Override + public Cursor moveToStartOfPreviousLine() { + position = textBuffer.getStartOfPreviousLine(position); + return this; + } + + @Override + public Cursor moveToStartOfNextLine() { + position = textBuffer.getStartOfNextLine(position); + return this; + } + + @Override + public Cursor moveToStartOf(int lineIndex) { + validateLineIndex(lineIndex); + position = new Position(lineIndex, 0); + return this; + } + + @Override + public Cursor moveToEndOfLine() { + position = textBuffer.getEndOfLine(position); + return this; + } + + @Override + public Cursor moveToEndOfPreviousLine() { + return moveToStartOfPreviousLine().moveToEndOfLine(); + } + + @Override + public Cursor moveToEndOfNextLine() { + return moveToStartOfNextLine().moveToEndOfLine(); + } + + @Override + public Cursor moveToEndOf(int lineIndex) { + return moveToStartOf(lineIndex).moveToEndOfLine(); + } + + @Override + public Cursor moveForward() { + return moveForward(1); + } + + @Override + public Cursor moveForward(int times) { + position = textBuffer.forward(position, times); + return this; + } + + @Override + public Cursor moveBackward() { + return moveBackward(1); + } + + @Override + public Cursor moveBackward(int times) { + position = textBuffer.backward(position, times); + return this; + } + + @Override + public Cursor moveTo(Mark mark) { + validateMark(mark); + position = mark.position(); + return this; + } + + @Override + public boolean skipBackward(String text) { + String prefix = getPrefix(); + if (prefix.endsWith(text)) { + position = new Position(position.lineIndex(), position.columnIndex() - text.length()); + return true; + } else { + return false; + } + } + + @Override + public boolean skipForward(String text) { + String suffix = getSuffix(); + if (suffix.startsWith(text)) { + position = new Position(position.lineIndex(), position.columnIndex() + text.length()); + return true; + } else { + return false; + } + } + + @Override + public Optional<Match> moveForwardToStartOfMatch(Pattern pattern) { + return moveForwardToXOfMatch(pattern, match -> position = match.startOfMatch()); + } + + @Override + public Optional<Match> moveForwardToEndOfMatch(Pattern pattern) { + return moveForwardToXOfMatch(pattern, match -> position = match.endOfMatch()); + } + + private Optional<Match> moveForwardToXOfMatch(Pattern pattern, Consumer<Match> callback) { + Optional<Match> match = textBuffer.findForward(position, pattern); + match.ifPresent(callback); + return match; + } + + @Override + public Cursor moveTo(Position position) { + validatePosition(position); + this.position = position; + return this; + } + + @Override + public Cursor moveTo(int lineIndex, int columnIndex) { + return moveTo(new Position(lineIndex, columnIndex)); + } + + @Override + public Cursor write(String text) { + position = textBuffer.write(position, text); + return this; + } + + @Override + public Cursor writeLine(String line) { + return write(line).write("\n"); + } + + @Override + public Cursor writeLines(String... lines) { + return writeLines(Arrays.asList(lines)); + } + + @Override + public Cursor writeLines(Iterable<String> lines) { + return writeLine(String.join("\n", lines)); + } + + @Override + public Cursor writeNewline() { + return write("\n"); + } + + @Override + public Cursor writeNewlineAfter() { + return writeNewline().moveBackward(); + } + + @Override + public Cursor deleteAll() { + moveToStartOfBuffer(); + textBuffer.clear(); + return this; + } + + @Override + public Cursor deleteLine() { + moveToStartOfLine(); + textBuffer.delete(position, textBuffer.getStartOfNextLine(position)); + return this; + } + + @Override + public Cursor deletePrefix() { + Position originalPosition = position; + moveToStartOfLine(); + textBuffer.delete(position, originalPosition); + return this; + } + + @Override + public Cursor deleteSuffix() { + textBuffer.delete(position, textBuffer.getEndOfLine(position)); + return this; + } + + @Override + public Cursor deleteForward() { + return deleteForward(1); + } + + @Override + public Cursor deleteForward(int times) { + Position end = textBuffer.forward(position, times); + textBuffer.delete(position, end); + return this; + } + + @Override + public Cursor deleteBackward() { + return deleteBackward(1); + } + + @Override + public Cursor deleteBackward(int times) { + Position end = position; + moveBackward(times); + textBuffer.delete(position, end); + return this; + } + + @Override + public Cursor deleteTo(Mark mark) { + validateMark(mark); + Position start = min(mark.position(), position); + Position end = max(mark.position(), position); + + textBuffer.delete(start, end); + return this; + } + + @Override + public boolean replaceMatch(Pattern pattern, Function<Match, String> replacer) { + Optional<Match> match = moveForwardToStartOfMatch(pattern); + if (!match.isPresent()) { + return false; + } + + textBuffer.delete(match.get().startOfMatch(), match.get().endOfMatch()); + write(replacer.apply(match.get())); + return true; + } + + @Override + public int replaceMatches(Pattern pattern, Function<Match, String> replacer) { + int count = 0; + + for (; replaceMatch(pattern, replacer); ++count) { + // empty + } + + return count; + } + + private void validatePosition(Position position) { + validateLineIndex(position.lineIndex()); + + int maxColumnIndex = textBuffer.getLine(position.lineIndex()).length(); + if (position.columnIndex() < 0 || position.columnIndex() > maxColumnIndex) { + throw new IndexOutOfBoundsException("Column index of " + position.coordinateString() + + " is not in permitted range [0," + maxColumnIndex + "]"); + } + } + + private void validateLineIndex(int lineIndex) { + int maxLineIndex = textBuffer.getMaxLineIndex(); + if (lineIndex < 0 || lineIndex > maxLineIndex) { + throw new IndexOutOfBoundsException("Line index " + lineIndex + + " not in permitted range [0," + maxLineIndex + "]"); + } + } + + private void validateMark(Mark mark) { + if (mark.secret() != unique) { + throw new IllegalArgumentException("Unknown mark " + mark); + } + + if (!mark.version().equals(textBuffer.getVersion())) { + throw new IllegalArgumentException("Mark " + mark + " is outdated"); + } + } +} diff --git a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/editor/FileEditor.java b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/editor/FileEditor.java new file mode 100644 index 00000000000..9896c86e286 --- /dev/null +++ b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/editor/FileEditor.java @@ -0,0 +1,58 @@ +// Copyright 2018 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. + +package com.yahoo.vespa.hosted.node.admin.task.util.editor; + +import com.yahoo.vespa.hosted.node.admin.task.util.file.UnixPath; + +import java.nio.file.Path; + +/** + * @author hakon + */ +public class FileEditor { + private final UnixPath path; + private final StringEditor stringEditor; + + private String fileText; + private Version fileVersion; + + public static FileEditor open(Path path) { + UnixPath unixPath = new UnixPath(path); + String text = unixPath.readUtf8File(); + StringEditor stringEditor = new StringEditor(text); + return new FileEditor(unixPath, text, stringEditor); + } + + private FileEditor(UnixPath path, String fileText, StringEditor stringEditor) { + this.path = path; + this.fileText = fileText; + this.stringEditor = stringEditor; + fileVersion = stringEditor.bufferVersion(); + } + + public Cursor cursor() { + return stringEditor.cursor(); + } + + public void reloadFile() { + fileText = path.readUtf8File(); + stringEditor.cursor().deleteAll().write(fileText); + fileVersion = stringEditor.bufferVersion(); + } + + public boolean save() { + Version bufferVersion = stringEditor.bufferVersion(); + if (bufferVersion.equals(fileVersion)) { + return false; + } + + String newText = stringEditor.cursor().getBufferText(); + if (newText.equals(fileText)) { + return false; + } + + path.writeUtf8File(newText); + fileVersion = bufferVersion; + return true; + } +} diff --git a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/editor/Mark.java b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/editor/Mark.java new file mode 100644 index 00000000000..5de570d5910 --- /dev/null +++ b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/editor/Mark.java @@ -0,0 +1,54 @@ +// Copyright 2018 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. + +package com.yahoo.vespa.hosted.node.admin.task.util.editor; + +import javax.annotation.concurrent.Immutable; +import java.util.Objects; + +/** + * @author hakon + */ +@Immutable +public class Mark { + private final Position position; + private final Version version; + private final Object token; + + Mark(Position position, Version version, Object token) { + this.position = position; + this.version = version; + this.token = token; + } + + public Position position() { + return position; + } + + public Version version() { + return version; + } + + public Object secret() { + return token; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Mark mark = (Mark) o; + return Objects.equals(position, mark.position) && + Objects.equals(version, mark.version) && + token == mark.token; + } + + @Override + public int hashCode() { + return Objects.hash(position, version, token); + } + + @Override + public String toString() { + return position.coordinateString() + "@" + version; + } +} diff --git a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/editor/Match.java b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/editor/Match.java new file mode 100644 index 00000000000..a1b5b7e2200 --- /dev/null +++ b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/editor/Match.java @@ -0,0 +1,53 @@ +// Copyright 2018 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. + +package com.yahoo.vespa.hosted.node.admin.task.util.editor; + +import java.util.regex.Matcher; + +/** + * Represents a pattern match of a line + * + * @author hakon + */ +public class Match { + private final int lineIndex; + private final String line; + private final Matcher matcher; + + Match(int lineIndex, String line, Matcher matcher) { + this.lineIndex = lineIndex; + this.line = line; + this.matcher = matcher; + } + + /** The part of the line before the match */ + public String prefix() { + return line.substring(0, matcher.start()); + } + + /** The part of the line that matched */ + public String match() { + return matcher.group(); + } + + /** The part of the line that followed the match */ + public String suffix() { + return line.substring(matcher.end()); + } + + public Position startOfMatch() { + return new Position(lineIndex, matcher.start()); + } + + public Position endOfMatch() { + return new Position(lineIndex, matcher.end()); + } + + public int groupCount() { + return matcher.groupCount(); + } + + public String group(int groupnr) { + return matcher.group(groupnr); + } +} diff --git a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/editor/Position.java b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/editor/Position.java new file mode 100644 index 00000000000..f69b850ee91 --- /dev/null +++ b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/editor/Position.java @@ -0,0 +1,74 @@ +// Copyright 2018 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.node.admin.task.util.editor; + +import javax.annotation.concurrent.Immutable; +import java.util.Comparator; +import java.util.Objects; + +/** + * Represents a position in the buffer + * + * @author hakon + */ +@Immutable +public class Position implements Comparable<Position> { + private static final Position START_POSITION = new Position(0, 0); + + private static final Comparator<Position> COMPARATOR = Comparator + .comparingInt(Position::lineIndex) + .thenComparingInt(Position::columnIndex); + + private final int lineIndex; + private final int columnIndex; + + /** Returns the first position at line index 0 and column index 0 */ + public static Position start() { + return START_POSITION; + } + + Position(int lineIndex, int columnIndex) { + this.lineIndex = lineIndex; + this.columnIndex = columnIndex; + } + + public int lineIndex() { + return lineIndex; + } + + public int columnIndex() { + return columnIndex; + } + + @Override + public int compareTo(Position that) { + return COMPARATOR.compare(this, that); + } + + public boolean isAfter(Position that) { return compareTo(that) > 0; } + public boolean isNotAfter(Position that) { return !isAfter(that); } + public boolean isBefore(Position that) { return compareTo(that) < 0; } + public boolean isNotBefore(Position that) { return !isBefore(that); } + + public String coordinateString() { + return "(" + lineIndex + "," + columnIndex + ")"; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Position position = (Position) o; + return lineIndex == position.lineIndex && + columnIndex == position.columnIndex; + } + + @Override + public int hashCode() { + return Objects.hash(lineIndex, columnIndex); + } + + @Override + public String toString() { + return coordinateString(); + } +} diff --git a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/editor/StringEditor.java b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/editor/StringEditor.java new file mode 100644 index 00000000000..b6875c79e18 --- /dev/null +++ b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/editor/StringEditor.java @@ -0,0 +1,30 @@ +// Copyright 2018 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. + +package com.yahoo.vespa.hosted.node.admin.task.util.editor; + +/** + * Edits multi-line text. + * + * @author hakon + */ +public class StringEditor { + private final TextBuffer textBuffer; + private final Cursor cursor; + + public StringEditor() { + this(""); + } + + public StringEditor(String text) { + textBuffer = new TextBufferImpl(text); + cursor = new CursorImpl(textBuffer); + } + + public Cursor cursor() { + return cursor; + } + + public Version bufferVersion() { + return textBuffer.getVersion(); + } +} diff --git a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/editor/TextBuffer.java b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/editor/TextBuffer.java new file mode 100644 index 00000000000..1a224d5fab6 --- /dev/null +++ b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/editor/TextBuffer.java @@ -0,0 +1,175 @@ +// Copyright 2018 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. + +package com.yahoo.vespa.hosted.node.admin.task.util.editor; + +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * @author hakon + */ +interface TextBuffer { + // INTERFACE TO IMPLEMENT BY CONCRETE CLASS + + /** Get the version of the buffer - edits increment the version. */ + Version getVersion(); + + /** Return the text as a single String (likely) with embedded newlines. */ + String getString(); + + /** Return the maximum line index (the minimum line index is 0). */ + int getMaxLineIndex(); + + /** @param lineIndex must be in in {@code [0, getMaxLineIndex()]} */ + String getLine(int lineIndex); + + /** Insert the possibly multi-line text at position and return the end position. */ + Position write(Position position, String text); + + /** Delete everything. */ + void clear(); + + /** Delete range. */ + void delete(Position start, Position end); + + // DERIVED IMPLEMENTATION + + /** + * Return the Position closest to {@code position} which is in the range + * {@code [getStartOfText(), getEndOfText()]}. + */ + default Position getValidPositionClosestTo(Position position) { + if (position.isBefore(getStartOfText())) { + return getStartOfText(); + } else if (position.isAfter(getEndOfText())) { + return getEndOfText(); + } else { + return position; + } + } + + default String getLine(Position position) { return getLine(position.lineIndex()); } + + default String getLinePrefix(Position position) { + return getLine(position.lineIndex()).substring(0, position.columnIndex()); + } + + default String getLineSuffix(Position position) { + return getLine(position.lineIndex()).substring(position.columnIndex()); + } + + default String getSubstring(Position start, Position end) { + if (start.lineIndex() < end.lineIndex()) { + StringBuilder builder = new StringBuilder(getLineSuffix(start)); + for (int i = start.lineIndex() + 1; i < end.lineIndex(); ++i) { + builder.append('\n'); + builder.append(getLine(i)); + } + return builder.append('\n').append(getLinePrefix(end)).toString(); + } else if (start.lineIndex() == end.lineIndex() && start.columnIndex() <= end.columnIndex()) { + return getLine(start).substring(start.columnIndex(), end.columnIndex()); + } + + throw new IllegalArgumentException( + "Bad range [" + start.coordinateString() + "," + end.coordinateString() + ">"); + } + + default Position getStartOfText() { return Position.start(); } // aka (0,0) + + default Position getEndOfText() { + int maxLineIndex = getMaxLineIndex(); + return new Position(maxLineIndex, getLine(maxLineIndex).length()); + } + + default Position getStartOfLine(Position position) { + return new Position(position.lineIndex(), 0); + } + + default Position getEndOfLine(Position position) { + return new Position(position.lineIndex(), getLine(position).length()); + } + + default Position getStartOfNextLine(Position position) { + if (position.lineIndex() < getMaxLineIndex()) { + return new Position(position.lineIndex() + 1, 0); + } else { + return getEndOfText(); + } + } + + default Position getStartOfPreviousLine(Position position) { + int lineIndex = position.lineIndex(); + if (lineIndex > 0) { + return new Position(lineIndex - 1, 0); + } else { + return getStartOfText(); + } + } + + default Position forward(Position position, int length) { + int lineIndex = position.lineIndex(); + int columnIndex = position.columnIndex(); + + int offsetLeft = length; + do { + String line = getLine(lineIndex); + int columnIndexWithInfiniteLine = columnIndex + offsetLeft; + if (columnIndexWithInfiniteLine <= line.length()) { + return new Position(lineIndex, columnIndexWithInfiniteLine); + } else if (lineIndex >= getMaxLineIndex()) { + // End of text + return new Position(lineIndex, line.length()); + } + + offsetLeft -= line.length() - columnIndex; + + // advance past newline + --offsetLeft; + ++lineIndex; + columnIndex = 0; + + // At this point: offsetLeft is guaranteed to be >= 0, and lineIndex <= max line index + } while (true); + } + + default Position backward(Position position, int length) { + int lineIndex = position.lineIndex(); + int columnIndex = position.columnIndex(); + + int offsetLeft = length; + do { + int columnIndexWithInfiniteLine = columnIndex - offsetLeft; + if (columnIndexWithInfiniteLine >= 0) { + return new Position(lineIndex, columnIndexWithInfiniteLine); + } else if (lineIndex <= 0) { + // Start of text + return new Position(0, 0); + } + + offsetLeft -= columnIndex; + + // advance past newline + --offsetLeft; + --lineIndex; + columnIndex = getLine(lineIndex).length(); + + // At this point: offsetLeft is guaranteed to be <= 0, and lineIndex >= 0 + } while (true); + } + + default Optional<Match> findForward(Position startPosition, Pattern pattern) { + for (Position position = startPosition; ; position = getStartOfNextLine(position)) { + String line = getLine(position); + Matcher matcher = pattern.matcher(line); + if (matcher.find(position.columnIndex())) { + return Optional.of(new Match(position.lineIndex(), line, matcher)); + } + + if (position.lineIndex() == getMaxLineIndex()) { + // search failed - no lines matched + return Optional.empty(); + } + } + } +} diff --git a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/editor/TextBufferImpl.java b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/editor/TextBufferImpl.java new file mode 100644 index 00000000000..e09fc4deec0 --- /dev/null +++ b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/editor/TextBufferImpl.java @@ -0,0 +1,117 @@ +// Copyright 2018 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. + +package com.yahoo.vespa.hosted.node.admin.task.util.editor; + +import java.util.ArrayList; +import java.util.LinkedList; +import java.util.List; + +import static com.yahoo.vespa.hosted.node.admin.task.util.editor.TextUtil.splitString; + +/** + * @author hakon + */ +public class TextBufferImpl implements TextBuffer { + /** Invariant: {@code size() >= 1}. An empty text buffer {@code => [""]} */ + private final ArrayList<String> lines = new ArrayList<>(); + + private Version version = new Version(); + + TextBufferImpl() { + lines.add(""); + } + + TextBufferImpl(String text) { + this(); + write(getStartOfText(), text); + // reset version + version = new Version(); + } + + @Override + public Version getVersion() { + return version; + } + + @Override + public String getString() { + return String.join("\n", lines); + } + + @Override + public int getMaxLineIndex() { + return lines.size() - 1; + } + + @Override + public String getLine(int lineIndex) { + return lines.get(lineIndex); + } + + @Override + public Position write(Position position, String text) { + List<String> linesToInsert = new LinkedList<>(splitString(text, true, false)); + if (linesToInsert.isEmpty()) { + return position; + } + + // The position splits that line in two, and both prefix and suffix must be preserved + linesToInsert.set(0, getLinePrefix(position) + linesToInsert.get(0)); + String lastLine = linesToInsert.get(linesToInsert.size() - 1); + int endColumnIndex = lastLine.length(); + linesToInsert.set(linesToInsert.size() - 1, lastLine + getLineSuffix(position)); + + // Set the first line at lineIndex, insert the rest. + int lineIndex = position.lineIndex(); + int endLineIndex = lineIndex + linesToInsert.size() - 1; + lines.set(lineIndex, linesToInsert.remove(0)); + lines.addAll(lineIndex + 1, linesToInsert); + + incrementVersion(); + + return new Position(endLineIndex, endColumnIndex); + } + + @Override + public void clear() { + lines.clear(); + lines.add(""); + } + + @Override + public void delete(Position start, Position end) { + if (start.isAfter(end)) { + throw new IllegalArgumentException("start position " + start + + " is after end position " + end); + } + + String prefix = getLinePrefix(start); + String suffix = getLineSuffix(end); + String stichedLine = prefix + suffix; + + lines.set(start.lineIndex(), stichedLine); + + deleteLines(start.lineIndex() + 1, end.lineIndex() + 1); + + incrementVersion(); + } + + private void deleteLines(int startIndex, int endIndex) { + for (int fromIndex = endIndex, toIndex = startIndex; fromIndex <= getMaxLineIndex(); + ++toIndex, ++fromIndex) { + lines.set(toIndex, lines.get(fromIndex)); + } + + truncate(getMaxLineIndex() - (endIndex - startIndex)); + } + + private void truncate(int newMaxLineIndex) { + while (getMaxLineIndex() > newMaxLineIndex) { + lines.remove(getMaxLineIndex()); + } + } + + private void incrementVersion() { + version = version.next(); + } +} diff --git a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/editor/TextUtil.java b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/editor/TextUtil.java new file mode 100644 index 00000000000..410ccbfc880 --- /dev/null +++ b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/editor/TextUtil.java @@ -0,0 +1,59 @@ +// Copyright 2018 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. + +package com.yahoo.vespa.hosted.node.admin.task.util.editor; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +/** + * @author hakon + */ +public class TextUtil { + private TextUtil() {} + + /** + * Splits {@code text} by newline (LF {@code '\n'}). + * + * @param text the text to split into lines + * @param empty whether an empty text implies an empty List (true), or a List with one + * empty String element (false) + * @param prune whether a text ending with a newline will result in a List ending with the + * preceding line (true), or to add an empty String element (false) + */ + public static List<String> splitString(String text, boolean empty, boolean prune) { + List<String> lines = new ArrayList<>(); + splitString(text, empty, prune, lines::add); + return lines; + } + + /** + * Splits text by newline, passing each line to a consumer. + * + * @see #splitString(String, boolean, boolean) + */ + public static void splitString(String text, + boolean empty, + boolean prune, + Consumer<String> consumer) { + if (text.isEmpty()) { + if (!empty) { + consumer.accept(text); + } + return; + } + + final int endIndex = text.length(); + + int start = 0; + for (int end = text.indexOf('\n'); + end != -1; + start = end + 1, end = text.indexOf('\n', start)) { + consumer.accept(text.substring(start, end)); + } + + if (start < endIndex || !prune) { + consumer.accept(text.substring(start)); + } + } +} diff --git a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/editor/Version.java b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/editor/Version.java new file mode 100644 index 00000000000..d40197db470 --- /dev/null +++ b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/editor/Version.java @@ -0,0 +1,54 @@ +// Copyright 2018 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. + +package com.yahoo.vespa.hosted.node.admin.task.util.editor; + +import javax.annotation.concurrent.Immutable; +import java.util.Objects; + +/** + * Represents a snapshot of the TextBuffer, between two edits (or the initial or final state) + * + * @author hakon + */ +@Immutable +public class Version { + private final int version; + + Version() { + this(0); + } + + private Version(int version) { + this.version = version; + } + + public boolean isBefore(Version that) { + return version < that.version; + } + + public int asInt() { + return version; + } + + public Version next() { + return new Version(version + 1); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Version that = (Version) o; + return version == that.version; + } + + @Override + public int hashCode() { + return Objects.hash(version); + } + + @Override + public String toString() { + return String.valueOf(version); + } +} diff --git a/node-admin/src/test/java/com/yahoo/vespa/hosted/node/admin/task/util/editor/StringEditorTest.java b/node-admin/src/test/java/com/yahoo/vespa/hosted/node/admin/task/util/editor/StringEditorTest.java new file mode 100644 index 00000000000..b1bf5c12dd0 --- /dev/null +++ b/node-admin/src/test/java/com/yahoo/vespa/hosted/node/admin/task/util/editor/StringEditorTest.java @@ -0,0 +1,151 @@ +// Copyright 2018 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. + +package com.yahoo.vespa.hosted.node.admin.task.util.editor; + +import org.junit.Test; + +import java.util.Optional; +import java.util.regex.Pattern; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +public class StringEditorTest { + private final StringEditor editor = new StringEditor(); + private final Cursor cursor = editor.cursor(); + + @Test + public void testBasics() { + assertCursor(0, 0, ""); + + cursor.write("hello"); + assertCursor(0, 5, "hello"); + + cursor.write("one\ntwo"); + assertCursor(1, 3, "helloone\ntwo"); + + cursor.deleteAll(); + assertCursor(0, 0, ""); + + cursor.moveForward(); + assertCursor(0, 0, ""); + + cursor.writeLine("foo"); + assertCursor(1, 0, "foo\n"); + + cursor.writeLines("one", "two"); + assertCursor(3, 0, "foo\none\ntwo\n"); + + cursor.deleteBackward(); + assertCursor(2, 3, "foo\none\ntwo"); + + cursor.deleteBackward(2); + assertCursor(2, 1, "foo\none\nt"); + + Mark mark = cursor.createMark(); + + cursor.moveToStartOfPreviousLine().moveBackward(2); + assertCursor(0, 2, "foo\none\nt"); + + assertEquals("o\none\nt", cursor.getTextTo(mark)); + + cursor.deleteTo(mark); + assertCursor(0, 2, "fo"); + + cursor.deleteBackward(2); + assertCursor(0, 0, ""); + + cursor.writeLines("one", "two", "three").moveToStartOfBuffer(); + assertCursor(0, 0, "one\ntwo\nthree\n"); + + Pattern pattern = Pattern.compile("t(.)"); + Optional<Match> match = cursor.moveForwardToEndOfMatch(pattern); + assertCursor(1, 2, "one\ntwo\nthree\n"); + assertTrue(match.isPresent()); + assertEquals("tw", match.get().match()); + assertEquals("", match.get().prefix()); + assertEquals("o", match.get().suffix()); + assertEquals(new Position(1, 0), match.get().startOfMatch()); + assertEquals(new Position(1, 2), match.get().endOfMatch()); + assertEquals(1, match.get().groupCount()); + assertEquals("w", match.get().group(1)); + + match = cursor.moveForwardToEndOfMatch(pattern); + assertCursor(2, 2, "one\ntwo\nthree\n"); + assertTrue(match.isPresent()); + assertEquals("th", match.get().match()); + assertEquals(1, match.get().groupCount()); + assertEquals("h", match.get().group(1)); + + match = cursor.moveForwardToEndOfMatch(pattern); + assertCursor(2, 2, "one\ntwo\nthree\n"); + assertFalse(match.isPresent()); + + assertTrue(cursor.skipBackward("h")); + assertCursor(2, 1, "one\ntwo\nthree\n"); + assertFalse(cursor.skipBackward("x")); + + assertTrue(cursor.skipForward("hre")); + assertCursor(2, 4, "one\ntwo\nthree\n"); + assertFalse(cursor.skipForward("x")); + + try { + cursor.moveTo(mark); + fail(); + } catch (IllegalArgumentException e) { + // expected + } + + mark = cursor.createMark(); + cursor.moveToStartOfBuffer(); + assertEquals(new Position(0, 0), cursor.getPosition()); + cursor.moveTo(mark); + assertEquals(new Position(2, 4), cursor.getPosition()); + + cursor.moveTo(1, 2); + assertCursor(1, 2, "one\ntwo\nthree\n"); + + cursor.deleteSuffix(); + assertCursor(1, 2, "one\ntw\nthree\n"); + + cursor.deletePrefix(); + assertCursor(1, 0, "one\n\nthree\n"); + + cursor.deleteLine(); + assertCursor(1, 0, "one\nthree\n"); + + cursor.deleteLine(); + assertCursor(1, 0, "one\n"); + + cursor.deleteLine(); + assertCursor(1, 0, "one\n"); + + cursor.moveToStartOfBuffer().moveForward().writeNewlineAfter(); + assertCursor(0, 1, "o\nne\n"); + + cursor.deleteAll().writeLines("one", "two", "three", "four"); + cursor.moveToStartOfBuffer().moveToStartOfNextLine(); + assertCursor(1, 0, "one\ntwo\nthree\nfour\n"); + Pattern pattern2 = Pattern.compile("(o)(.)?"); + int count = cursor.replaceMatches(pattern2, m -> { + String prefix = m.group(2) == null ? "" : m.group(2); + return prefix + m.match() + m.group(1); + }); + assertCursor(3, 5, "one\ntwoo\nthree\nfuouor\n"); + assertEquals(2, count); + + cursor.moveToStartOfBuffer().moveToEndOfLine(); + Pattern pattern3 = Pattern.compile("o"); + count = cursor.replaceMatches(pattern3, m -> "a"); + assertEquals(4, count); + assertCursor(3, 5, "one\ntwaa\nthree\nfuauar\n"); + } + + private void assertCursor(int lineIndex, int columnIndex, String text) { + assertEquals(text, cursor.getBufferText()); + assertEquals(new Position(lineIndex, columnIndex), cursor.getPosition()); + } + +}
\ No newline at end of file diff --git a/node-admin/src/test/java/com/yahoo/vespa/hosted/node/admin/task/util/editor/TextBufferImplTest.java b/node-admin/src/test/java/com/yahoo/vespa/hosted/node/admin/task/util/editor/TextBufferImplTest.java new file mode 100644 index 00000000000..619b1de4877 --- /dev/null +++ b/node-admin/src/test/java/com/yahoo/vespa/hosted/node/admin/task/util/editor/TextBufferImplTest.java @@ -0,0 +1,59 @@ +// Copyright 2018 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. + +package com.yahoo.vespa.hosted.node.admin.task.util.editor; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + +public class TextBufferImplTest { + private final TextBufferImpl textBuffer = new TextBufferImpl(); + + @Test + public void testWrite() { + assertEquals("", textBuffer.getString()); + assertWrite(2, 0, "foo\nbar\n", + 0, 0, "foo\nbar\n"); + + assertWrite(1, 6, "fofirst\nsecondo\nbar\n", + 0, 2, "first\nsecond"); + + assertWrite(3, 1, "fofirst\nsecondo\nbar\na", + 3, 0, "a"); + assertWrite(4, 0, "fofirst\nsecondo\nbar\na\n", + 3, 1, "\n"); + } + + @Test + public void testDelete() { + write(0, 0, "foo\nbar\nzoo\n"); + delete(0, 2, 2, 1); + assertEquals("fooo\n", textBuffer.getString()); + + delete(0, 4, 1, 0); + assertEquals("fooo", textBuffer.getString()); + + delete(0, 0, 0, 4); + assertEquals("", textBuffer.getString()); + + delete(0, 0, 0, 0); + assertEquals("", textBuffer.getString()); + } + + private void assertWrite(int expectedLineIndex, int expectedColumnIndex, String expectedString, + int lineIndex, int columnIndex, String text) { + Position position = write(lineIndex, columnIndex, text); + assertEquals(new Position(expectedLineIndex, expectedColumnIndex), position); + assertEquals(expectedString, textBuffer.getString()); + } + + private Position write(int lineIndex, int columnIndex, String text) { + return textBuffer.write(new Position(lineIndex, columnIndex), text); + } + + private void delete(int startLineIndex, int startColumnIndex, + int endLineIndex, int endColumnIndex) { + textBuffer.delete(new Position(startLineIndex, startColumnIndex), + new Position(endLineIndex, endColumnIndex)); + } +}
\ No newline at end of file |