summaryrefslogtreecommitdiffstats
path: root/node-admin/src
diff options
context:
space:
mode:
authorHÃ¥kon Hallingstad <hakon@oath.com>2018-05-07 12:23:08 +0200
committerGitHub <noreply@github.com>2018-05-07 12:23:08 +0200
commitc852de1e8352ff7323f2a4b2dac3cceb62561fb2 (patch)
tree5da82d20dc27652c6086af7fd30e9304f41d1f76 /node-admin/src
parente7d292dbf1525ae004a40365e2327379b6474e2b (diff)
parentb5181039ad233c4c28820ee9217334bcddb70e3f (diff)
Merge pull request #5783 from vespa-engine/hakonhall/procedural-editor
Procedural editor
Diffstat (limited to 'node-admin/src')
-rw-r--r--node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/editor/Cursor.java96
-rw-r--r--node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/editor/CursorImpl.java357
-rw-r--r--node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/editor/FileEditor.java58
-rw-r--r--node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/editor/Mark.java54
-rw-r--r--node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/editor/Match.java53
-rw-r--r--node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/editor/Position.java74
-rw-r--r--node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/editor/StringEditor.java30
-rw-r--r--node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/editor/TextBuffer.java175
-rw-r--r--node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/editor/TextBufferImpl.java117
-rw-r--r--node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/editor/TextUtil.java59
-rw-r--r--node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/task/util/editor/Version.java54
-rw-r--r--node-admin/src/test/java/com/yahoo/vespa/hosted/node/admin/task/util/editor/StringEditorTest.java151
-rw-r--r--node-admin/src/test/java/com/yahoo/vespa/hosted/node/admin/task/util/editor/TextBufferImplTest.java59
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