         return row unless @isRowBlank(row)
     null
 
-  # Deprecate
-  usesSoftTabs: ->
-    Grim.deprecate("Use Editor::usesSoftTabs instead. TextBuffer doesn't have enough context to determine this.")
-    for row in [0..@getLastRow()]
-      if match = @lineForRow(row).match(/^\s/)
-        return match[0][0] != '\t'
-    undefined
-
-  # Deprecated: Call {::setTextInRange} instead.
-  change: (oldRange, newText, options={}) ->
-    Grim.deprecate("Use TextBuffer::setTextInRange instead.")
-    @setTextInRange(oldRange, newText, options.normalizeLineEndings)
-
-  cancelStoppedChangingTimeout: ->
-    clearTimeout(@stoppedChangingTimeout) if @stoppedChangingTimeout
-
-  scheduleModifiedEvents: ->
-    @cancelStoppedChangingTimeout()
-    stoppedChangingCallback = =>
-      @stoppedChangingTimeout = null
-      modifiedStatus = @isModified()
-      @emit 'contents-modified', modifiedStatus
-      @emitter.emit 'did-stop-changing'
-      @emitModifiedStatusChanged(modifiedStatus)
-    @stoppedChangingTimeout = setTimeout(stoppedChangingCallback, @stoppedChangingDelay)
-
-  emitModifiedStatusChanged: (modifiedStatus) ->
-    return if modifiedStatus is @previousModifiedStatus
-    @previousModifiedStatus = modifiedStatus
-    @emit 'modified-status-changed', modifiedStatus
-    @emitter.emit 'did-change-modified', modifiedStatus
-
-  logLines: (start=0, end=@getLastRow())->
-    for row in [start..end]
-      line = @lineForRow(row)
-      console.log row, line, line.length
-
-  @delegatesMethods 'undo', 'redo', 'transact', 'beginTransaction', 'commitTransaction',
-    'abortTransaction', 'isTransacting', 'clearUndoStack', toProperty: 'history'
-
-  # Public: Undo the last operation. If a transaction is in progress, aborts it.
-  undo: -> @history.undo()
-
-  # Public: Redo the last operation
-  redo: -> @history.redo()
-
-  # Public: Batch multiple operations as a single undo/redo step.
-  #
-  # Any group of operations that are logically grouped from the perspective of
-  # undoing and redoing should be performed in a transaction. If you want to
-  # abort the transaction, call {::abortTransaction} to terminate the function's
-  # execution and revert any changes performed up to the abortion.
-  #
-  # * `fn` A {Function} to call inside the transaction.
-  transact: (fn) -> @history.transact(fn)
-
-  # Public: Start an open-ended transaction.
-  #
-  # Call {::commitTransaction} or {::abortTransaction} to terminate the
-  # transaction. If you nest calls to transactions, only the outermost
-  # transaction is considered. You must match every begin with a matching
-  # commit, but a single call to abort will cancel all nested transactions.
-  beginTransaction: -> @history.beginTransaction()
-
-  # Public: Commit an open-ended transaction started with {::beginTransaction}
-  # and push it to the undo stack.
-  #
-  # If transactions are nested, only the outermost commit takes effect.
-  commitTransaction: -> @history.commitTransaction()
-
-  # Public: Abort an open transaction, undoing any operations performed so far
-  # within the transaction.
-  abortTransaction: -> @history.abortTransaction()
-
-  # Public: Clear the undo stack.
-  clearUndoStack: -> @history.clearUndoStack()
+  ###
+  Section: Mutating Text
+  ###
+
+  # Public: Replace the entire contents of the buffer with the given text.
+  #
+  # * `text` A {String}
+  #
+  # Returns a {Range} spanning the new buffer contents.
+  setText: (text) ->
+    @setTextInRange(@getRange(), text, false)
+
+  # Public: Replace the current buffer contents by applying a diff based on the
+  # given text.
+  #
+  # * `text` A {String} containing the new buffer contents.
+  setTextViaDiff: (text) ->
+    currentText = @getText()
+    return if currentText == text
+
+    endsWithNewline = (str) ->
+      /[\r\n]+$/g.test(str)
+
+    computeBufferColumn = (str) ->
+      newlineIndex = Math.max(str.lastIndexOf('\n'), str.lastIndexOf('\r'))
+      if endsWithNewline(str)
+        0
+      else if newlineIndex == -1
+        str.length
+      else
+        str.length - newlineIndex - 1
+
+    @transact =>
+      row = 0
+      column = 0
+      currentPosition = [0, 0]
+
+      lineDiff = diff.diffLines(currentText, text)
+      changeOptions = normalizeLineEndings: false
+
+      for change in lineDiff
+        lineCount = change.value.match(newlineRegex)?.length ? 0
+        currentPosition[0] = row
+        currentPosition[1] = column
+
+        if change.added
+          @setTextInRange([currentPosition, currentPosition], change.value, changeOptions)
+          row += lineCount
+          column = computeBufferColumn(change.value)
+
+        else if change.removed
+          endRow = row + lineCount
+          endColumn = column + computeBufferColumn(change.value)
+          @setTextInRange([currentPosition, [endRow, endColumn]], '', changeOptions)
+
+        else
+          row += lineCount
+          column = computeBufferColumn(change.value)
+
+  # Public: Set the text in the given range.
+  #
+  # * `range` A {Range}
+  # * `text` A {String}
+  # * `normalizeLineEndings` (optional) {Boolean} (default: true)
+  #
+  # Returns the {Range} of the inserted text.
+  setTextInRange: (range, text, normalizeLineEndings=true) ->
+    patch = @buildPatch(range, text, normalizeLineEndings)
+    @history?.recordNewPatch(patch)
+    @applyPatch(patch)
+    patch.newRange
+
+  # Public: Insert text at the given position.
+  #
+  # * `position` A {Point} representing the insertion location. The position is
+  #   clipped before insertion.
+  # * `text` A {String} representing the text to insert.
+  # * `normalizeLineEndings` (optional) {Boolean} (default: true)\
+  #
+  # Returns the {Range} of the inserted text.
+  insert: (position, text, normalizeLineEndings) ->
+    @setTextInRange(new Range(position, position), text, normalizeLineEndings)
+
+  # Public: Append text to the end of the buffer.
+  #
+  # * `text` A {String} representing the text text to append.
+  #
+  # Returns the {Range} of the inserted text
+  append: (text, normalizeLineEndings) ->
+    @insert(@getEndPosition(), text, normalizeLineEndings)
+
+  # Builds a {BufferPatch}, which is used to modify the buffer and is also
+  # pushed into the undo history so it can be undone.
+  buildPatch: (oldRange, newText, normalizeLineEndings) ->
+    oldRange = @clipRange(oldRange)
+    oldText = @getTextInRange(oldRange)
+    newRange = Range.fromText(oldRange.start, newText)
+    patch = new BufferPatch(oldRange, newRange, oldText, newText, normalizeLineEndings)
+    @markers?.handleBufferChange(patch)
+    patch
+
+  # Applies a {BufferPatch} to the buffer based on its old range and new text.
+  # Also applies any {MarkerPatch}es associated with the {BufferPatch}.
+  applyPatch: ({oldRange, newRange, oldText, newText, normalizeLineEndings, markerPatches}) ->
+    @cachedText = null
+
+    startRow = oldRange.start.row
+    endRow = oldRange.end.row
+    rowCount = endRow - startRow + 1
+
+    # Determine how to normalize the line endings of inserted text if enabled
+    if normalizeLineEndings
+      normalizedEnding = @lineEndingForRow(startRow)
+      if normalizedEnding is ''
+        if startRow > 0
+          normalizedEnding = @lineEndingForRow(startRow - 1)
+        else
+          normalizedEnding = null
+
+    # Split inserted text into lines and line endings
+    lines = []
+    lineEndings = []
+    lineStartIndex = 0
+    while result = newlineRegex.exec(newText)
+      lines.push(newText[lineStartIndex...result.index])
+      lineEndings.push(normalizedEnding ? result[0])
+      lineStartIndex = newlineRegex.lastIndex
+
+    lastLine = newText[lineStartIndex..]
+    lines.push(lastLine)
+    lineEndings.push('')
+
+    # Update first and last line so replacement preserves existing prefix and suffix of oldRange
+    prefix = @lineForRow(startRow)[0...oldRange.start.column]
+    lines[0] = prefix + lines[0]
+    suffix = @lineForRow(endRow)[oldRange.end.column...]
+    lastIndex = lines.length - 1
+    lines[lastIndex] += suffix
+    lastLineEnding = @lineEndingForRow(endRow)
+    lastLineEnding = normalizedEnding if lastLineEnding isnt '' and normalizedEnding?
+    lineEndings[lastIndex] = lastLineEnding
+
+    # Replace lines in oldRange with new lines
+    spliceArray(@lines, startRow, rowCount, lines)
+    spliceArray(@lineEndings, startRow, rowCount, lineEndings)
+
+    # Update the offset index for position <-> character offset translation
+    offsets = lines.map (line, index) ->
+      {rows: 1, characters: line.length + lineEndings[index].length}
+    @offsetIndex.spliceArray('rows', startRow, rowCount, offsets)
+
+    @markers?.pauseChangeEvents()
+    @markers?.applyPatches(markerPatches, true)
+
+    changeEvent = {oldRange, newRange, oldText, newText}
+
+    @conflict = false if @conflict and !@isModified()
+    @scheduleModifiedEvents()
+    @emit 'changed', changeEvent
+    @emitter.emit 'did-change', changeEvent
+    @markers?.resumeChangeEvents()
+    @emit 'markers-updated'
+    @emitter.emit 'did-update-markers'
+
+  ###
+  Section: Removing Text
+  ###
+
+  # Public: Delete the text in the given range.
+  #
+  # * `range` A {Range} in which to delete. The range is clipped before deleting.
+  #
+  # Returns an empty {Range} starting at the start of deleted range.
+  delete: (range) ->
+    @setTextInRange(range, '')
+
+  # Public: Delete the line associated with a specified row.
+  #
+  # * `row` A {Number} representing the 0-indexed row to delete.
+  #
+  # Returns the {Range} of the deleted text.
+  deleteRow: (row) ->
+    @deleteRows(row, row)
+
+  # Public: Delete the lines associated with the specified row range.
+  #
+  # If the row range is out of bounds, it will be clipped. If the startRow is
+  # greater than the end row, they will be reordered.
+  #
+  # * `startRow` A {Number} representing the first row to delete.
+  # * `endRow` A {Number} representing the last row to delete, inclusive.
+  #
+  # Returns the {Range} of the deleted text.
+  deleteRows: (startRow, endRow) ->
+    lastRow = @getLastRow()
+
+    [startRow, endRow] = [endRow, startRow] if startRow > endRow
+
+    if endRow < 0
+      return new Range(@getFirstPosition(), @getFirstPosition())
+
+    if startRow > lastRow
+      return new Range(@getEndPosition(), @getEndPosition())
+
+    startRow = Math.max(0, startRow)
+    endRow = Math.min(lastRow, endRow)
+
+    if endRow < lastRow
+      startPoint = new Point(startRow, 0)
+      endPoint = new Point(endRow + 1, 0)
+    else
+      if startRow is 0
+        startPoint = new Point(startRow, 0)
+      else
+        startPoint = new Point(startRow - 1, @lineLengthForRow(startRow - 1))
+      endPoint = new Point(endRow, @lineLengthForRow(endRow))
+
+    @delete(new Range(startPoint, endPoint))
+
+  ###
+  Section: Markers
+  ###
 
   # Public: Create a marker with the given range. This marker will maintain
   # its logical location as the buffer is changed, so if you mark a particular

    filePath: @getPath()
    modifiedWhenLastPersisted: @isModified()
    digestWhenLastPersisted: @file?.getDigest()

  # Public: Invoke the given callback synchronously when the content of the
  # buffer changes.


    EmitterMixin::on.apply(this, arguments)

  ###
  Section: Metadata
  ###

  # Public: Determine whether the buffer is empty.
  #
  # Returns a {Boolean}.
  isEmpty: ->
    @getLastRow() is 0 and @lineLengthForRow(0) is 0

  # Public: Determine if the in-memory contents of the buffer differ from its
  # contents on disk.
  #
  # If the buffer is unsaved, always returns `true` unless the buffer is empty.
  #
  # Returns a {Boolean}.
  isModified: ->
    return false unless @loaded
    if @file
      if @file.exists()
        @getText() != @cachedDiskContents
      else
        @wasModifiedBeforeRemove ? not @isEmpty()
    else
      not @isEmpty()

  # Public: Determine if the in-memory contents of the buffer conflict with the
  # on-disk contents of its associated file.
  #
  # Returns a {Boolean}.
  isInConflict: -> @conflict

  # Public: Get the number of milliseconds that will elapse without a change
  # before {::onDidStopChanging} observers are invoked following a change.
  #
  # Returns a {Number}.
  getStoppedChangingDelay: -> @stoppedChangingDelay

  ###
  Section: Path Data
  ###

  # Pubilc: Get the path of the associated file.
  #
  # Returns a {String}.
  getPath: ->
    @file?.getPath()

  # Public: Set the path for the buffer's associated file.
  #
  # * `filePath` A {String} representing the new file path
  setPath: (filePath) ->
    return if filePath == @getPath()

    if filePath
      @file = new File(filePath)
      @subscribeToFile()
    else
      @file = null

    @emit "path-changed", this
    @emitter.emit 'did-change-path', @getPath()

  # Public: Get the path of the associated file.
  #
  # Returns a {String}.
  getUri: ->
    @getPath()

  # Get the basename of the associated file.
  #
  # The basename is the name portion of the file's path, without the containing
  # directories.
  #
  # Returns a {String}.
  getBaseName: ->
    @file?.getBaseName()

  ###
  Section: Reading Text
  ###

  # Public: Get the entire text of the buffer.
  #
  # Returns a {String}.
