Changes to SmartSemicolonHandler:
- Use text edits rather than a semantic update to insert a tag number, to avoid
  duplication of surrounding whitespace.
- Correctly determine the next tag number within a message containing groups.
- Detect and update a next-tag-number comment located at the end of a message.

Change-Id: Ie8967f3393ef6f9b2e1fbb0118916f7e82c8f8a0
diff --git a/com.google.eclipse.protobuf.test/src/com/google/eclipse/protobuf/junit/core/XtextRule.java b/com.google.eclipse.protobuf.test/src/com/google/eclipse/protobuf/junit/core/XtextRule.java
index b95bb21..e20c5c6 100644
--- a/com.google.eclipse.protobuf.test/src/com/google/eclipse/protobuf/junit/core/XtextRule.java
+++ b/com.google.eclipse.protobuf.test/src/com/google/eclipse/protobuf/junit/core/XtextRule.java
@@ -113,6 +113,10 @@
     return root;
   }
 
+  public String text() {
+    return resource.getParseResult().getRootNode().getText();
+  }
+
   public <T extends EObject> T find(String name, String extra, Class<T> type, SearchOption...options) {
     return find(name + extra, name.length(), type, options);
   }
diff --git a/com.google.eclipse.protobuf.ui.test/src/com/google/eclipse/protobuf/ui/commands/semicolon/CommentNodesFinder_matchingCommentNode_Test.java b/com.google.eclipse.protobuf.ui.test/src/com/google/eclipse/protobuf/ui/commands/semicolon/CommentNodesFinder_matchingCommentNode_Test.java
deleted file mode 100644
index 24bb3d0..0000000
--- a/com.google.eclipse.protobuf.ui.test/src/com/google/eclipse/protobuf/ui/commands/semicolon/CommentNodesFinder_matchingCommentNode_Test.java
+++ /dev/null
@@ -1,79 +0,0 @@
-/*
- * Copyright (c) 2011 Google Inc.
- *
- * All rights reserved. This program and the accompanying materials are made available under the terms of the Eclipse
- * Public License v1.0 which accompanies this distribution, and is available at
- *
- * http://www.eclipse.org/legal/epl-v10.html
- */
-package com.google.eclipse.protobuf.ui.commands.semicolon;
-
-import static org.hamcrest.core.IsEqual.equalTo;
-import static org.junit.Assert.assertNotNull;
-import static org.junit.Assert.assertNull;
-import static org.junit.Assert.assertThat;
-
-import static com.google.eclipse.protobuf.junit.core.UnitTestModule.unitTestModule;
-import static com.google.eclipse.protobuf.junit.core.XtextRule.overrideRuntimeModuleWith;
-
-import java.util.regex.Matcher;
-
-import org.eclipse.emf.ecore.EObject;
-import org.eclipse.xtext.nodemodel.INode;
-import org.eclipse.xtext.util.Pair;
-import org.junit.Rule;
-import org.junit.Test;
-
-import com.google.eclipse.protobuf.junit.core.XtextRule;
-import com.google.eclipse.protobuf.protobuf.MessageField;
-import com.google.inject.Inject;
-
-/**
- * Tests for <code>{@link CommentNodesFinder#matchingCommentNode(EObject, String...)}</code>.
- *
- * @author alruiz@google.com (Alex Ruiz)
- */
-public class CommentNodesFinder_matchingCommentNode_Test {
-  @Rule public XtextRule xtext = overrideRuntimeModuleWith(unitTestModule());
-
-  @Inject private CommentNodesFinder finder;
-
-  // syntax = "proto2";
-  //
-  // message Person {
-  //   // Next Id: 6
-  //   optional bool active = 1;
-  // }
-  @Test public void should_return_matching_single_line_comment_of_element() {
-    MessageField field = xtext.find("active", MessageField.class);
-    Pair<INode, Matcher> match = finder.matchingCommentNode(field, "next id: [\\d]+");
-    INode node = match.getFirst();
-    assertThat(node.getText().trim(), equalTo("// Next Id: 6"));
-  }
-
-  // syntax = "proto2";
-  //
-  // message Person {
-  //   /*
-  //    * Next Id: 6
-  //    */
-  //   optional bool active = 1;
-  // }
-  @Test public void should_return_matching_multi_line_comment_of_element() {
-    MessageField field = xtext.find("active", MessageField.class);
-    Pair<INode, Matcher> match = finder.matchingCommentNode(field, "NEXT ID: [\\d]+");
-    assertNotNull(match.getFirst());
-  }
-
-  // syntax = "proto2";
-  //
-  // message Person {
-  //   // Next Id: 6
-  //   optional bool active = 1;
-  // }
-  @Test public void should_return_null_if_no_matching_node_found() {
-    MessageField active = xtext.find("active", MessageField.class);
-    Pair<INode, Matcher> match = finder.matchingCommentNode(active, "Hello");
-    assertNull(match);
-  }
-}
diff --git a/com.google.eclipse.protobuf.ui.test/src/com/google/eclipse/protobuf/ui/commands/semicolon/SmartSemicolonHandlerTest.java b/com.google.eclipse.protobuf.ui.test/src/com/google/eclipse/protobuf/ui/commands/semicolon/SmartSemicolonHandlerTest.java
new file mode 100644
index 0000000..1956883
--- /dev/null
+++ b/com.google.eclipse.protobuf.ui.test/src/com/google/eclipse/protobuf/ui/commands/semicolon/SmartSemicolonHandlerTest.java
@@ -0,0 +1,275 @@
+/*
+ * Copyright (c) 2015 Google Inc.
+ *
+ * All rights reserved. This program and the accompanying materials are made available under the terms of the Eclipse
+ * Public License v1.0 which accompanies this distribution, and is available at
+ *
+ * http://www.eclipse.org/legal/epl-v10.html
+ */
+package com.google.eclipse.protobuf.ui.commands.semicolon;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+
+import org.eclipse.swt.custom.StyledText;
+import org.eclipse.swt.custom.StyledTextContent;
+import org.eclipse.text.edits.ReplaceEdit;
+import org.eclipse.text.edits.TextEdit;
+import org.eclipse.xtext.nodemodel.ICompositeNode;
+import org.eclipse.xtext.nodemodel.util.NodeModelUtils;
+import org.junit.Rule;
+import org.junit.Test;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+
+import com.google.eclipse.protobuf.junit.core.XtextRule;
+import com.google.eclipse.protobuf.protobuf.Group;
+import com.google.eclipse.protobuf.protobuf.Literal;
+import com.google.eclipse.protobuf.protobuf.MessageField;
+import com.google.eclipse.protobuf.ui.plugin.ProtobufEditorPlugIn;
+import com.google.inject.Inject;
+
+public class SmartSemicolonHandlerTest {
+  @Rule
+  public XtextRule xtext = XtextRule.createWith(ProtobufEditorPlugIn.injector());
+
+  @Inject
+  private SmartSemicolonHandler handler;
+
+  // // ignore errors
+  // syntax = "proto2";
+  //
+  // message Message {
+  //   optional bool incomplete
+  // }
+  @Test public void shouldDetermineFirstIndexToBe1() {
+    MessageField incomplete = xtext.find("incomplete", MessageField.class);
+    assertThat(handler.determineNewIndex(incomplete), is(1L));
+  }
+
+  // // ignore errors
+  // syntax = "proto2";
+  //
+  // message Message {
+  //   optional bool in_message = 2;
+  //   optional group outer_group = 4 {
+  //     optional bool in_outer_group = 5;
+  //     optional group inner_group = 3 {
+  //       optional bool in_inner_group = 1;
+  //       optional bool incomplete
+  //     }
+  //   }
+  // }
+  @Test public void shouldDetermineCorrectIndexInsideOfGroups() {
+    MessageField incomplete = xtext.find("incomplete", MessageField.class);
+    assertThat(handler.determineNewIndex(incomplete), is(6L));
+  }
+
+  // // ignore errors
+  // syntax = "proto2";
+  //
+  // message Message {
+  //   optional bool in_message = 2;
+  //   optional group outer_group = 4 {
+  //     optional bool in_outer_group = 5;
+  //     optional group inner_group = 3 {
+  //       optional bool in_inner_group = 1;
+  //     }
+  //   }
+  //   optional bool incomplete
+  // }
+  @Test public void shouldDetermineCorrectIndexOutsideOfGroups() {
+    MessageField incomplete = xtext.find("incomplete", MessageField.class);
+    assertThat(handler.determineNewIndex(incomplete), is(6L));
+  }
+
+  // // ignore errors
+  // syntax = "proto2";
+  //
+  // message Message {
+  //   optional bool in_message = 2;
+  //   message InnerMessage {
+  //     optional bool in_inner_message = 4;
+  //     optional bool incomplete
+  //   }
+  // }
+  @Test public void shouldDetermineCorrectIndexInsideOfNestedMessage() {
+    MessageField incomplete = xtext.find("incomplete", MessageField.class);
+    assertThat(handler.determineNewIndex(incomplete), is(5L));
+  }
+
+  // // ignore errors
+  // syntax = "proto2";
+  //
+  // message Message {
+  //   optional bool in_message = 2;
+  //   message InnerMessage {
+  //     optional bool in_inner_message = 4;
+  //   }
+  //   optional bool incomplete
+  // }
+  @Test public void shouldDetermineCorrectIndexOutsideOfNestedMessage() {
+    MessageField incomplete = xtext.find("incomplete", MessageField.class);
+    assertThat(handler.determineNewIndex(incomplete), is(3L));
+  }
+
+  // // ignore errors
+  // syntax = "proto2";
+  //
+  // message Message {
+  //   optional bool incomplete
+  // }
+  @Test public void shouldComplete() {
+    String incompleteFieldName = "incomplete";
+
+    MessageField incomplete = xtext.find(incompleteFieldName, MessageField.class);
+    ICompositeNode node = NodeModelUtils.getNode(incomplete);
+    ReplaceEdit indexEdit = handler.completeWithIndex(node, 1);
+
+    assertThat(indexEdit.getOffset(),
+        is(xtext.text().indexOf(incompleteFieldName) + incompleteFieldName.length()));
+    assertThat(indexEdit.getText(), is(" = 1;"));
+  }
+
+  // // ignore errors
+  // syntax = "proto2";
+  //
+  // message Message {
+  //   optional bool incomplete
+  //     =
+  // }
+  @Test public void shouldCompleteAfterExistingEquals() {
+    MessageField incomplete = xtext.find("incomplete", MessageField.class);
+    ICompositeNode node = NodeModelUtils.getNode(incomplete);
+    ReplaceEdit indexEdit = handler.completeWithIndex(node, 1);
+
+    String equalsAtStartOfLine = "    =";
+    assertThat(indexEdit.getOffset(),
+        is(xtext.text().indexOf(equalsAtStartOfLine) + equalsAtStartOfLine.length()));
+    assertThat(indexEdit.getText(), is(" 1;"));
+  }
+
+  // // ignore errors
+  // syntax = "proto2";
+  //
+  // message Message {
+  //   optional bool incomplete [ default = true; ];
+  // }
+  @Test public void shouldCompleteWithoutSemicolonBeforeOptionBracket() {
+    String incompleteFieldName = "incomplete";
+
+    MessageField incomplete = xtext.find(incompleteFieldName, MessageField.class);
+    ICompositeNode node = NodeModelUtils.getNode(incomplete);
+    ReplaceEdit indexEdit = handler.completeWithIndex(node, 1);
+
+    assertThat(indexEdit.getOffset(),
+        is(xtext.text().indexOf(incompleteFieldName) + incompleteFieldName.length()));
+    assertThat(indexEdit.getText(), is(" = 1 "));
+  }
+
+  // // ignore errors
+  // syntax = "proto2";
+  //
+  // message Message {
+  //   optional group incomplete {
+  //   }
+  // }
+  @Test public void shouldCompleteWithoutSemicolonBeforeGroupBrace() {
+    String incompleteGroupName = "incomplete";
+
+    Group incomplete = xtext.find(incompleteGroupName, Group.class);
+    ICompositeNode node = NodeModelUtils.getNode(incomplete);
+    ReplaceEdit indexEdit = handler.completeWithIndex(node, 1);
+
+    assertThat(indexEdit.getOffset(),
+        is(xtext.text().indexOf(incompleteGroupName) + incompleteGroupName.length()));
+    assertThat(indexEdit.getText(), is(" = 1 "));
+  }
+
+  @Test public void shouldDeleteTrailingWhitespace() {
+    String trailingWhitespace = "    ";
+    String lineContent = "  optional bool foo" + trailingWhitespace;
+    int lineNumber = 10;
+    int lineStartOffset = 100;
+    int insertionOffset = lineStartOffset + lineContent.lastIndexOf(trailingWhitespace);
+
+    StyledTextContent content = Mockito.mock(StyledTextContent.class);
+    Mockito.when(content.getLineAtOffset(insertionOffset)).thenReturn(lineNumber);
+    Mockito.when(content.getOffsetAtLine(lineNumber)).thenReturn(lineStartOffset);
+    Mockito.when(content.getLine(lineNumber)).thenReturn(lineContent);
+
+    TextEdit trailingWhitespaceEdit = handler.deleteTrailingWhitespace(content, insertionOffset);
+    assertThat(trailingWhitespaceEdit.getOffset(), is(insertionOffset));
+    assertThat(trailingWhitespaceEdit.getLength(), is(trailingWhitespace.length()));
+  }
+
+  // // ignore errors
+  // syntax = "proto2";
+  //
+  // // Next Id: 2
+  // message Message {
+  //   optional bool field = 1;
+  //   optional bool incomplete
+  // }
+  @Test public void shouldUpdateNextIndexComment() {
+    MessageField incomplete = xtext.find("incomplete", MessageField.class);
+    ReplaceEdit commentEdit = handler.updateNextIndexComment(incomplete, 3);
+
+    String pattern = "Next Id: ";
+    assertThat(commentEdit.getOffset(), is(xtext.text().indexOf(pattern) + pattern.length()));
+    assertThat(commentEdit.getText(), is("3"));
+  }
+
+  // // ignore errors
+  // syntax = "proto2";
+  //
+  // /*
+  //  * Next Id: 2
+  //  */
+  // message Message {
+  //   optional bool field = 1;
+  //   optional bool incomplete
+  // }
+  @Test public void shouldUpdateMultilineComment() {
+    MessageField incomplete = xtext.find("incomplete", MessageField.class);
+    ReplaceEdit commentEdit = handler.updateNextIndexComment(incomplete, 3);
+
+    String pattern = "Next Id: ";
+    assertThat(commentEdit.getOffset(), is(xtext.text().indexOf(pattern) + pattern.length()));
+    assertThat(commentEdit.getText(), is("3"));
+  }
+
+  // // ignore errors
+  // syntax = "proto2";
+  //
+  // enum Enum {
+  //   ONE = 1;
+  //   TWO = 2;
+  //   THREE = 3;
+  //   FOUR
+  //   // Next Id: 4
+  // }
+  @Test public void shouldUpdateNextIndexCommentAtEndOfEnum() {
+    Literal incomplete = xtext.find("FOUR", Literal.class);
+    ReplaceEdit commentEdit = handler.updateNextIndexComment(incomplete, 5);
+
+    String pattern = "Next Id: ";
+    assertThat(commentEdit.getOffset(), is(xtext.text().indexOf(pattern) + pattern.length()));
+    assertThat(commentEdit.getText(), is("5"));
+  }
+
+  // // ignore errors
+  // syntax = "proto2";
+  //
+  // // My Favorite Number: 200
+  // message Message {
+  //   optional bool field = 1;
+  //   optional bool incomplete
+  // }
+  @Test public void shouldNotUpdateOtherComment() {
+    MessageField incomplete = xtext.find("incomplete", MessageField.class);
+    ReplaceEdit commentEdit = handler.updateNextIndexComment(incomplete, 3);
+
+    assertThat(commentEdit, is((ReplaceEdit) null));
+  }
+}
diff --git a/com.google.eclipse.protobuf.ui/src/com/google/eclipse/protobuf/ui/commands/semicolon/CommentNodesFinder.java b/com.google.eclipse.protobuf.ui/src/com/google/eclipse/protobuf/ui/commands/semicolon/CommentNodesFinder.java
deleted file mode 100644
index 296f715..0000000
--- a/com.google.eclipse.protobuf.ui/src/com/google/eclipse/protobuf/ui/commands/semicolon/CommentNodesFinder.java
+++ /dev/null
@@ -1,98 +0,0 @@
-/*
- * Copyright (c) 2011 Google Inc.
- *
- * All rights reserved. This program and the accompanying materials are made available under the terms of the Eclipse
- * Public License v1.0 which accompanies this distribution, and is available at
- *
- * http://www.eclipse.org/legal/epl-v10.html
- */
-package com.google.eclipse.protobuf.ui.commands.semicolon;
-
-import static com.google.common.cache.CacheBuilder.newBuilder;
-import static com.google.common.collect.Lists.newArrayList;
-import static com.google.eclipse.protobuf.util.Strings.quote;
-import static com.google.eclipse.protobuf.util.SystemProperties.lineSeparator;
-import static java.util.regex.Pattern.CASE_INSENSITIVE;
-import static org.eclipse.xtext.nodemodel.util.NodeModelUtils.getNode;
-import static org.eclipse.xtext.util.Strings.isEmpty;
-import static org.eclipse.xtext.util.Tuples.pair;
-
-import com.google.common.cache.CacheLoader;
-import com.google.common.cache.LoadingCache;
-import com.google.eclipse.protobuf.model.util.INodes;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-
-import org.apache.log4j.Logger;
-import org.eclipse.emf.ecore.EObject;
-import org.eclipse.xtext.nodemodel.ICompositeNode;
-import org.eclipse.xtext.nodemodel.ILeafNode;
-import org.eclipse.xtext.nodemodel.INode;
-import org.eclipse.xtext.util.Pair;
-
-import java.util.List;
-import java.util.concurrent.ExecutionException;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
-
-/**
- * @author alruiz@google.com (Alex Ruiz)
- */
-@Singleton class CommentNodesFinder {
-  private static final String MATCH_ANYTHING = ".*";
-
-  private static Logger logger = Logger.getLogger(CommentNodesFinder.class);
-
-  @Inject private INodes nodes;
-
-  private final LoadingCache<String, Pattern> patternCache =
-      newBuilder().maximumSize(20).build(new PatternCacheLoader());
-
-  Pair<INode, Matcher> matchingCommentNode(EObject target, String... patternsToMatch) {
-    ICompositeNode node = getNode(target);
-    for (INode currentNode : node.getAsTreeIterable()) {
-      if (!nodes.isHiddenLeafNode(currentNode) || !nodes.isComment(currentNode)) {
-        continue;
-      }
-      String rawComment = ((ILeafNode) currentNode).getText();
-      if (isEmpty(rawComment)) {
-        continue;
-      }
-      String[] comment = rawComment.split(lineSeparator());
-      for (String line : comment) {
-        for (Pattern pattern : compile(patternsToMatch)) {
-          Matcher matcher = pattern.matcher(line);
-          if (matcher.matches()) {
-            return pair(currentNode, matcher);
-          }
-        }
-      }
-    }
-    return null;
-  }
-
-  private List<Pattern> compile(String[] patterns) {
-    List<Pattern> compiled = newArrayList();
-    for (final String s : patterns) {
-      Pattern p = null;
-      try {
-        p = patternCache.get(s);
-      } catch (ExecutionException e) {
-        logger.error("Unable to obtain pattern from cache for " + quote(s), e);
-        p = PatternCacheLoader.compile(s);
-      }
-      compiled.add(p);
-    }
-    return compiled;
-  }
-
-  private static class PatternCacheLoader extends CacheLoader<String, Pattern> {
-    @Override public Pattern load(String key) throws Exception {
-      return compile(key);
-    }
-
-    static Pattern compile(String regex) {
-      return Pattern.compile(MATCH_ANYTHING + regex + MATCH_ANYTHING, CASE_INSENSITIVE);
-    }
-  }
-}
diff --git a/com.google.eclipse.protobuf.ui/src/com/google/eclipse/protobuf/ui/commands/semicolon/SmartSemicolonHandler.java b/com.google.eclipse.protobuf.ui/src/com/google/eclipse/protobuf/ui/commands/semicolon/SmartSemicolonHandler.java
index 149425c..005cca6 100644
--- a/com.google.eclipse.protobuf.ui/src/com/google/eclipse/protobuf/ui/commands/semicolon/SmartSemicolonHandler.java
+++ b/com.google.eclipse.protobuf.ui/src/com/google/eclipse/protobuf/ui/commands/semicolon/SmartSemicolonHandler.java
@@ -1,5 +1,5 @@
 /*
- * Copyright (c) 2011 Google Inc.
+ * Copyright (c) 2015 Google Inc.
  *
  * All rights reserved. This program and the accompanying materials are made available under the terms of the Eclipse
  * Public License v1.0 which accompanies this distribution, and is available at
@@ -12,58 +12,68 @@
 import static java.util.regex.Pattern.compile;
 import static org.eclipse.xtext.util.Strings.isEmpty;
 
-import com.google.common.collect.Lists;
-import com.google.eclipse.protobuf.grammar.CommonKeyword;
+import com.google.common.annotations.VisibleForTesting;
 import com.google.eclipse.protobuf.model.util.INodes;
 import com.google.eclipse.protobuf.model.util.IndexedElements;
 import com.google.eclipse.protobuf.model.util.Literals;
 import com.google.eclipse.protobuf.model.util.Protobufs;
 import com.google.eclipse.protobuf.model.util.Resources;
+import com.google.eclipse.protobuf.protobuf.Enum;
 import com.google.eclipse.protobuf.protobuf.FieldOption;
+import com.google.eclipse.protobuf.protobuf.Group;
 import com.google.eclipse.protobuf.protobuf.IndexedElement;
 import com.google.eclipse.protobuf.protobuf.Literal;
+import com.google.eclipse.protobuf.protobuf.Message;
+import com.google.eclipse.protobuf.protobuf.MessageElement;
 import com.google.eclipse.protobuf.protobuf.MessageField;
-import com.google.eclipse.protobuf.protobuf.Protobuf;
 import com.google.eclipse.protobuf.ui.commands.SmartInsertHandler;
 import com.google.eclipse.protobuf.ui.preferences.editor.numerictag.NumericTagPreferences;
 import com.google.inject.Inject;
 
 import org.apache.log4j.Logger;
-import org.eclipse.emf.ecore.EAttribute;
 import org.eclipse.emf.ecore.EObject;
 import org.eclipse.jface.text.BadLocationException;
+import org.eclipse.jface.text.IRegion;
+import org.eclipse.jface.text.Region;
 import org.eclipse.swt.custom.StyledText;
+import org.eclipse.swt.custom.StyledTextContent;
+import org.eclipse.text.edits.DeleteEdit;
+import org.eclipse.text.edits.MultiTextEdit;
+import org.eclipse.text.edits.ReplaceEdit;
+import org.eclipse.text.edits.TextEdit;
+import org.eclipse.xtext.EcoreUtil2;
+import org.eclipse.xtext.RuleCall;
+import org.eclipse.xtext.nodemodel.ILeafNode;
 import org.eclipse.xtext.nodemodel.INode;
+import org.eclipse.xtext.nodemodel.util.NodeModelUtils;
 import org.eclipse.xtext.resource.XtextResource;
 import org.eclipse.xtext.ui.editor.XtextEditor;
 import org.eclipse.xtext.ui.editor.contentassist.ContentAssistContext;
 import org.eclipse.xtext.ui.editor.contentassist.antlr.ParserBasedContentAssistContextFactory;
 import org.eclipse.xtext.ui.editor.model.IXtextDocument;
 import org.eclipse.xtext.ui.editor.preferences.IPreferenceStoreAccess;
-import org.eclipse.xtext.util.Pair;
-import org.eclipse.xtext.util.Tuples;
 import org.eclipse.xtext.util.concurrent.IUnitOfWork;
 
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.HashSet;
 import java.util.List;
-import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.Set;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
 /**
- * Inserts a semicolon at the end of a line, regardless of the current position of the caret in the editor. If the line
- * of code being edited is a field or enum literal and if it does not have an index yet, this handler will insert an
- * index with a proper value as well.
+ * Handles a semicolon keypress either by completing the element at the cursor position with a tag
+ * number if that element is a message field, group, or enum literal lacking a tag number, or
+ * otherwise by inserting a semicolon at the cursor position.
  */
 public class SmartSemicolonHandler extends SmartInsertHandler {
+  private static final String SEMICOLON = ";";
   private static final Pattern NUMBERS_PATTERN = compile("[\\d]+");
 
-  private static final IUnitOfWork.Void<XtextResource> NULL_UNIT_OF_WORK = new IUnitOfWork.Void<XtextResource>() {
-    @Override public void process(XtextResource resource) {}
-  };
-
   private static Logger logger = Logger.getLogger(SmartSemicolonHandler.class);
 
-  @Inject private CommentNodesFinder commentNodesFinder;
   @Inject private ParserBasedContentAssistContextFactory contextFactory;
   @Inject private IndexedElements indexedElements;
   @Inject private Literals literals;
@@ -72,132 +82,240 @@
   @Inject private Resources resources;
   @Inject private IPreferenceStoreAccess storeAccess;
 
-  private static final String SEMICOLON = CommonKeyword.SEMICOLON.toString();
-
-  @Override protected void insertContent(XtextEditor editor, StyledText styledText) {
-    StyledTextAccess styledTextAccess = new StyledTextAccess(styledText);
-    String line = styledTextAccess.lineAtCaretOffset();
-    if (line.endsWith(SEMICOLON)) {
-      styledTextAccess.insert(SEMICOLON);
-      return;
-    }
-    insertContent(editor, styledTextAccess);
-    refreshHighlighting(editor);
-  }
-
-  private void insertContent(final XtextEditor editor, final StyledTextAccess styledTextAccess) {
-    final AtomicBoolean shouldInsertSemicolon = new AtomicBoolean(true);
+  @Override protected void insertContent(final XtextEditor editor, final StyledText styledText) {
     final IXtextDocument document = editor.getDocument();
-    final List<Pair<EObject, Long>> commentsToUpdate = Lists.newLinkedList();
 
-    document.readOnly(NULL_UNIT_OF_WORK); // wait for reconciler to finish its work.
-    try {
-      /*
-       * Textual and semantic updates cannot be done in the same IUnitOfWork (throws an 
-       * IllegalStateException), so index updates (semantic) are done first and tracked in the 
-       * commentsToUpdate list, then a 2nd IUnitOfWork processes the comment updates (textual).
-       */
-      document.modify(new IUnitOfWork.Void<XtextResource>() {
-        @Override public void process(XtextResource resource) {
-          Protobuf root = resources.rootOf(resource);
-          if (!protobufs.hasKnownSyntax(root)) {
-            return;
-          }
-          int offset = styledTextAccess.caretOffset();
-          ContentAssistContext[] context = contextFactory.create(editor.getInternalSourceViewer(), offset, resource);
-          for (ContentAssistContext c : context) {
-            if (nodes.isCommentOrString(c.getCurrentNode())) {
-              continue;
-            }
-            EObject model = modelFrom(c);
-            if (model instanceof FieldOption) {
-              FieldOption option = (FieldOption) model;
-              model = option.eContainer();
-            }
-            if (model instanceof Literal) {
-              Literal literal = (Literal) model;
-              if (shouldCalculateIndex(literal, LITERAL__INDEX)) {
-                long index = literals.calculateNewIndexOf(literal);
-                literal.setIndex(index);
-                commentsToUpdate.add(Tuples.create(model, index));
-                shouldInsertSemicolon.set(false);
-              }
-            }
-            if (model instanceof MessageField) {
-              MessageField field = (MessageField) model;
-              if (shouldCalculateIndex(field)) {
-                long index = indexedElements.calculateNewIndexFor(field);
-                field.setIndex(index);
-                commentsToUpdate.add(Tuples.create(model, index));
-                shouldInsertSemicolon.set(false);
-              }
-            }
-          }
-        }
-      });
-
-      if (!commentsToUpdate.isEmpty()) {
-        document.modify(new IUnitOfWork.Void<XtextResource>() {
-          @Override public void process(XtextResource resource) {
-            for (Pair<EObject, Long> updateInfo : commentsToUpdate) {
-              updateIndexInCommentOfParent(updateInfo.getFirst(), updateInfo.getSecond(), document);
-            }
-          }
-        });
-      }
-    } catch (Throwable t) {
-      shouldInsertSemicolon.set(true);
-      logger.error("Unable to generate tag number", t);
-    }
-    if (shouldInsertSemicolon.get()) {
-      styledTextAccess.insert(SEMICOLON);
-    }
-  }
-
-  private boolean shouldCalculateIndex(EObject target, EAttribute indexAttribute) {
-    INode node = nodes.firstNodeForFeature(target, indexAttribute);
-    return node == null || isEmpty(node.getText());
-  }
-  
-  private boolean shouldCalculateIndex(IndexedElement target) {
-    return indexedElements.indexOf(target) <= 0;
-  }
-
-  private EObject modelFrom(ContentAssistContext c) {
-    EObject current = c.getCurrentModel();
-    boolean isIndexed = current instanceof MessageField || current instanceof Literal;
-    return (isIndexed) ? current : c.getPreviousModel();
-  }
-
-  private void updateIndexInCommentOfParent(EObject target, long index, IXtextDocument document) {
-    EObject parent = target.eContainer();
-    if (parent == null) {
-      return;
-    }
-    NumericTagPreferences preferences = new NumericTagPreferences(storeAccess);
-    for (String pattern : preferences.patterns()) {
-      Pair<INode, Matcher> match = commentNodesFinder.matchingCommentNode(parent, pattern);
-      if (match == null) {
-        return;
-      }
-      String original = match.getSecond().group();
-      String replacement = NUMBERS_PATTERN.matcher(original).replaceFirst(String.valueOf(index + 1));
-      INode node = match.getFirst();
-      int offset = node.getTotalOffset() + node.getText().indexOf(original);
-      try {
-        document.replace(offset, original.length(), replacement);
-      } catch (BadLocationException e) {
-        String format = "Unable to update comment tracking next tag number using pattern '%s'";
-        logger.error(String.format(format, pattern), e);
-      }
-    }
-  }
-
-  private void refreshHighlighting(final XtextEditor editor) {
-    editor.getDocument().readOnly(new IUnitOfWork.Void<XtextResource>() {
+    document.modify(new IUnitOfWork.Void<XtextResource>() {
       @Override public void process(XtextResource resource) {
-        editor.getInternalSourceViewer().invalidateTextPresentation();
+        if (!protobufs.hasKnownSyntax(resources.rootOf(resource))) {
+          return;
+        }
+
+        EObject completableElement =
+            findCompletableElement(editor, styledText.getCaretOffset(), resource);
+
+        long newIndex = determineNewIndex(completableElement);
+        if (newIndex != -1) {
+          final TextEdit edit = new MultiTextEdit();
+
+          TextEdit indexEdit =
+              completeWithIndex(NodeModelUtils.getNode(completableElement), newIndex);
+          if (indexEdit != null) {
+            edit.addChild(indexEdit);
+
+            TextEdit trailingWhitespaceEdit =
+                deleteTrailingWhitespace(styledText.getContent(), indexEdit.getOffset());
+            if (trailingWhitespaceEdit != null) {
+              edit.addChild(trailingWhitespaceEdit);
+            }
+
+            long newNextIndex = newIndex + 1;
+            TextEdit commentEdit = updateNextIndexComment(completableElement, newNextIndex);
+            if (commentEdit != null) {
+              edit.addChild(commentEdit);
+            }
+
+            try {
+              edit.apply(document);
+
+              // Move the cursor to the end of the inserted completion text.
+              styledText.setCaretOffset(indexEdit.getExclusiveEnd());
+            } catch (BadLocationException e) {
+              logger.error("Failed to complete element with new tag number", e);
+            }
+          }
+        } else {
+          styledText.insert(SEMICOLON);
+          styledText.setCaretOffset(styledText.getCaretOffset() + SEMICOLON.length());
+        }
       }
     });
+
+    // Refresh syntax highlighting etc.
+    editor.getInternalSourceViewer().invalidateTextPresentation();
+  }
+
+  private EObject findCompletableElement(XtextEditor editor, int offset, XtextResource resource) {
+    ContentAssistContext[] contexts =
+        contextFactory.create(editor.getInternalSourceViewer(), offset, resource);
+
+    for (ContentAssistContext context : contexts) {
+      if (nodes.isCommentOrString(context.getCurrentNode())) {
+        continue;
+      }
+
+      for (EObject model : Arrays.asList(context.getCurrentModel(), context.getPreviousModel())) {
+        if (model instanceof FieldOption) {
+          model = model.eContainer();
+        }
+
+        if (model instanceof MessageField || model instanceof Group || model instanceof Literal) {
+          return model;
+        }
+      }
+    }
+
+    return null;
+  }
+
+  @VisibleForTesting long determineNewIndex(EObject model) {
+    if (model instanceof IndexedElement) {
+      IndexedElement indexedElement = (IndexedElement) model;
+      if (indexedElements.indexOf(indexedElement) <= 0) {
+        return indexedElements.calculateNewIndexFor(indexedElement);
+      }
+    } else if (model instanceof Literal) {
+      Literal literal = (Literal) model;
+      INode node = nodes.firstNodeForFeature(literal, LITERAL__INDEX);
+      if (node == null || isEmpty(node.getText())) {
+        return literals.calculateNewIndexOf(literal);
+      }
+    }
+
+    return -1;
+  }
+
+  @VisibleForTesting ReplaceEdit completeWithIndex(INode elementNode, long newIndex) {
+    INode nameNode = null;
+    INode equalsNode = null;
+    INode optionsBracketNode = null;
+    INode groupBraceNode = null;
+    for (INode leafNode : elementNode.getAsTreeIterable()) {
+      if (leafNode.getGrammarElement() instanceof RuleCall
+          && ((RuleCall) leafNode.getGrammarElement()).getRule().getName().equals("ID")) {
+        nameNode = leafNode;
+      } else {
+        String text = leafNode.getText();
+        if (text.equals("=")) {
+          equalsNode = leafNode;
+        } else if (text.equals("[")) {
+          optionsBracketNode = leafNode;
+        } else if (text.equals("{")) {
+          groupBraceNode = leafNode;
+        }
+      }
+    }
+
+    if (nameNode == null) {
+      return null;
+    }
+
+    StringBuilder replacement = new StringBuilder();
+
+    int start;
+    if (equalsNode != null) {
+      start = equalsNode.getTotalEndOffset();
+    } else {
+      start = nameNode.getTotalEndOffset();
+      replacement.append(" =");
+    }
+
+    replacement.append(" ");
+    replacement.append(newIndex);
+
+    int end;
+    if (optionsBracketNode != null) {
+      end = optionsBracketNode.getTotalOffset();
+      replacement.append(" ");
+    } else if (groupBraceNode != null) {
+      end = groupBraceNode.getTotalOffset();
+      replacement.append(" ");
+    } else {
+      end = elementNode.getTotalEndOffset();
+      if (elementNode.getGrammarElement() instanceof RuleCall
+          && ((RuleCall) elementNode.getGrammarElement()).getRule().getName().equals("Group")) {
+        // Insert a space after the index of a new group
+        // so that the user can easily continue typing { or [.
+        replacement.append(" ");
+      } else {
+        replacement.append(SEMICOLON);
+      }
+    }
+
+    return new ReplaceEdit(start, end - start, replacement.toString());
+  }
+
+  @VisibleForTesting TextEdit deleteTrailingWhitespace(StyledTextContent content, int offset) {
+    int lineAtOffset = content.getLineAtOffset(offset);
+    int offsetWithinLine = offset - content.getOffsetAtLine(lineAtOffset);
+    String lineText = content.getLine(lineAtOffset);
+
+    String trailingText = lineText.substring(offsetWithinLine);
+    int trailingTextLength = trailingText.length();
+
+    if (trailingText.trim().length() == 0) {
+      return new DeleteEdit(offset, trailingTextLength);
+    }
+
+    return null;
+  }
+
+  @VisibleForTesting ReplaceEdit updateNextIndexComment(
+      EObject completedElement, long newNextIndex) {
+    Class<? extends EObject> containingClass =
+        completedElement instanceof IndexedElement ? Message.class : Enum.class;
+    EObject containingElement = EcoreUtil2.getContainerOfType(completedElement, containingClass);
+    Iterable<ILeafNode> topLevelCommentNodes = findTopLevelCommentNodes(containingElement);
+
+    Collection<Pattern> patterns = compileIndexCommentPatterns();
+
+    IRegion indexLocation = findNextIndexInComments(topLevelCommentNodes, patterns);
+    if (indexLocation != null) {
+      return new ReplaceEdit(
+          indexLocation.getOffset(), indexLocation.getLength(), String.valueOf(newNextIndex));
+    }
+
+    return null;
+  }
+
+  private Iterable<ILeafNode> findTopLevelCommentNodes(EObject containingElement) {
+    Set<ILeafNode> nestedLeafNodes = new HashSet<>();
+    if (containingElement instanceof Message) {
+      Collection<MessageElement> nestedContainers = new ArrayList<>();
+      nestedContainers.addAll(EcoreUtil2.getAllContentsOfType(containingElement, Message.class));
+      nestedContainers.addAll(EcoreUtil2.getAllContentsOfType(containingElement, Enum.class));
+      for (MessageElement nestedContainer : nestedContainers) {
+        for (ILeafNode nestedLeafNode : NodeModelUtils.getNode(nestedContainer).getLeafNodes()) {
+          nestedLeafNodes.add(nestedLeafNode);
+        }
+      }
+    }
+
+    Collection<ILeafNode> topLevelCommentNodes = new ArrayList<>();
+    for (ILeafNode leafNode : NodeModelUtils.getNode(containingElement).getLeafNodes()) {
+      if (!nestedLeafNodes.contains(leafNode) && nodes.isComment(leafNode)) {
+        topLevelCommentNodes.add(leafNode);
+      }
+    }
+
+    return topLevelCommentNodes;
+  }
+
+  private Collection<Pattern> compileIndexCommentPatterns() {
+    List<String> regexes = new NumericTagPreferences(storeAccess).patterns();
+    Collection<Pattern> patterns = new ArrayList<>(regexes.size());
+    for (String regex : regexes) {
+      patterns.add(Pattern.compile(regex));
+    }
+    return patterns;
+  }
+
+  private IRegion findNextIndexInComments(
+      Iterable<ILeafNode> commentNodes, Collection<Pattern> patterns) {
+    for (ILeafNode commentNode : commentNodes) {
+      for (Pattern pattern : patterns) {
+        Matcher patternMatcher = pattern.matcher(commentNode.getText());
+        if (patternMatcher.find()) {
+          Matcher numberMatcher = NUMBERS_PATTERN.matcher(patternMatcher.group());
+          if (numberMatcher.find()) {
+            int matchStartPosition =
+                commentNode.getTotalOffset() + patternMatcher.start() + numberMatcher.start();
+            return new Region(matchStartPosition, numberMatcher.end() - numberMatcher.start());
+          }
+        }
+      }
+    }
+
+    return null;
   }
 }
diff --git a/com.google.eclipse.protobuf.ui/src/com/google/eclipse/protobuf/ui/commands/semicolon/StyledTextAccess.java b/com.google.eclipse.protobuf.ui/src/com/google/eclipse/protobuf/ui/commands/semicolon/StyledTextAccess.java
deleted file mode 100644
index 2f6bfda..0000000
--- a/com.google.eclipse.protobuf.ui/src/com/google/eclipse/protobuf/ui/commands/semicolon/StyledTextAccess.java
+++ /dev/null
@@ -1,46 +0,0 @@
-/*
- * Copyright (c) 2012 Google Inc.
- *
- * All rights reserved. This program and the accompanying materials are made available under the terms of the Eclipse
- * Public License v1.0 which accompanies this distribution, and is available at
- *
- * http://www.eclipse.org/legal/epl-v10.html
- */
-package com.google.eclipse.protobuf.ui.commands.semicolon;
-
-import org.eclipse.swt.custom.StyledText;
-
-/**
- * @author alruiz@google.com (Alex Ruiz)
- */
-class StyledTextAccess {
-  private final StyledText styledText;
-
-  StyledTextAccess(StyledText styledText) {
-    this.styledText = styledText;
-  }
-
-  String lineAtCaretOffset() {
-    int offset = caretOffset();
-    int lineAtOffset = styledText.getLineAtOffset(offset);
-    return styledText.getLine(lineAtOffset);
-  }
-
-  void setCaretOffsetToEndOfLine() {
-    int offset = caretOffset();
-    int lineAtOffset = styledText.getLineAtOffset(offset);
-    String line = styledText.getLine(lineAtOffset);
-    int offsetAtLine = styledText.getOffsetAtLine(lineAtOffset);
-    offset = offsetAtLine + line.length();
-    styledText.setCaretOffset(offset);
-  }
-
-  void insert(String text) {
-    styledText.insert(text);
-    styledText.setCaretOffset(caretOffset() + text.length());
-  }
-
-  int caretOffset() {
-    return styledText.getCaretOffset();
-  }
-}
diff --git a/com.google.eclipse.protobuf/src/com/google/eclipse/protobuf/model/util/IndexedElements.java b/com.google.eclipse.protobuf/src/com/google/eclipse/protobuf/model/util/IndexedElements.java
index 6a8208f..3061b1f 100644
--- a/com.google.eclipse.protobuf/src/com/google/eclipse/protobuf/model/util/IndexedElements.java
+++ b/com.google.eclipse.protobuf/src/com/google/eclipse/protobuf/model/util/IndexedElements.java
@@ -10,18 +10,21 @@
 
 import static java.lang.Math.max;
 import static java.util.Collections.emptyList;
-
 import static org.eclipse.xtext.util.SimpleAttributeResolver.newResolver;
 
 import java.util.List;
 
 import org.eclipse.emf.ecore.EObject;
 import org.eclipse.emf.ecore.EStructuralFeature;
+import org.eclipse.xtext.EcoreUtil2;
 import org.eclipse.xtext.util.SimpleAttributeResolver;
 
 import com.google.eclipse.protobuf.protobuf.FieldOption;
+import com.google.eclipse.protobuf.protobuf.Group;
 import com.google.eclipse.protobuf.protobuf.IndexedElement;
+import com.google.eclipse.protobuf.protobuf.Message;
 import com.google.eclipse.protobuf.protobuf.MessageElement;
+import com.google.eclipse.protobuf.protobuf.MessageField;
 import com.google.eclipse.protobuf.protobuf.OneOf;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -53,11 +56,11 @@
    * @return the calculated value for the index of the given element.
    */
   public long calculateNewIndexFor(IndexedElement e) {
-    EObject type = e.eContainer();
-    long index = findMaxIndex(type.eContents());
-    return ++index;
+    EObject containingMessage = EcoreUtil2.getContainerOfType(e, Message.class);
+    long index = findMaxIndex(containingMessage.eContents());
+    return index + 1;
   }
-  
+
   private long findMaxIndex(Iterable<? extends EObject> elements) {
     long maxIndex = 0;
 
@@ -66,17 +69,18 @@
         maxIndex = max(maxIndex, findMaxIndex(((OneOf) e).getElements()));
       } else if (e instanceof IndexedElement) {
         maxIndex  = max(maxIndex, indexOf((IndexedElement) e));
+        if (e instanceof Group) {
+          maxIndex = max(maxIndex, findMaxIndex(((Group) e).getElements()));
+        }
       }
     }
-    
+
     return maxIndex;
   }
 
   /**
-   * Returns the name of the given <code>{@link IndexedElement}</code>.
-   * @param e the given {@code IndexedElement}.
-   * @return the name of the given {@code IndexedElement}, or {@code Long.MIN_VALUE} if the given {@code IndexedElement}
-   * is {@code null}.
+   * Returns the index of the given {@link IndexedElement}, or {@code Long.MIN_VALUE} if the given
+   * {@code IndexedElement} is {@code null}.
    */
   public long indexOf(IndexedElement e) {
     long index = Long.MIN_VALUE;