src/selection.js

Summary

Maintainability
F
1 wk
Test Coverage
const { Point, Range } = require('text-buffer');
const { pick } = require('underscore-plus');
const { Emitter } = require('event-kit');

const NonWhitespaceRegExp = /\S/;
let nextId = 0;

// Extended: Represents a selection in the {TextEditor}.
module.exports = class Selection {
  constructor({ cursor, marker, editor, id }) {
    this.id = id != null ? id : nextId++;
    this.cursor = cursor;
    this.marker = marker;
    this.editor = editor;
    this.emitter = new Emitter();
    this.initialScreenRange = null;
    this.wordwise = false;
    this.cursor.selection = this;
    this.decoration = this.editor.decorateMarker(this.marker, {
      type: 'highlight',
      class: 'selection'
    });
    this.marker.onDidChange(e => this.markerDidChange(e));
    this.marker.onDidDestroy(() => this.markerDidDestroy());
  }

  destroy() {
    this.marker.destroy();
  }

  isLastSelection() {
    return this === this.editor.getLastSelection();
  }

  /*
  Section: Event Subscription
  */

  // Extended: Calls your `callback` when the selection was moved.
  //
  // * `callback` {Function}
  //   * `event` {Object}
  //     * `oldBufferRange` {Range}
  //     * `oldScreenRange` {Range}
  //     * `newBufferRange` {Range}
  //     * `newScreenRange` {Range}
  //     * `selection` {Selection} that triggered the event
  //
  // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
  onDidChangeRange(callback) {
    return this.emitter.on('did-change-range', callback);
  }

  // Extended: Calls your `callback` when the selection was destroyed
  //
  // * `callback` {Function}
  //
  // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
  onDidDestroy(callback) {
    return this.emitter.once('did-destroy', callback);
  }

  /*
  Section: Managing the selection range
  */

  // Public: Returns the screen {Range} for the selection.
  getScreenRange() {
    return this.marker.getScreenRange();
  }

  // Public: Modifies the screen range for the selection.
  //
  // * `screenRange` The new {Range} to use.
  // * `options` (optional) {Object} options matching those found in {::setBufferRange}.
  setScreenRange(screenRange, options) {
    return this.setBufferRange(
      this.editor.bufferRangeForScreenRange(screenRange),
      options
    );
  }

  // Public: Returns the buffer {Range} for the selection.
  getBufferRange() {
    return this.marker.getBufferRange();
  }

  // Public: Modifies the buffer {Range} for the selection.
  //
  // * `bufferRange` The new {Range} to select.
  // * `options` (optional) {Object} with the keys:
  //   * `reversed` {Boolean} indicating whether to set the selection in a
  //     reversed orientation.
  //   * `preserveFolds` if `true`, the fold settings are preserved after the
  //     selection moves.
  //   * `autoscroll` {Boolean} indicating whether to autoscroll to the new
  //     range. Defaults to `true` if this is the most recently added selection,
  //     `false` otherwise.
  setBufferRange(bufferRange, options = {}) {
    bufferRange = Range.fromObject(bufferRange);
    if (options.reversed == null) options.reversed = this.isReversed();
    if (!options.preserveFolds)
      this.editor.destroyFoldsContainingBufferPositions(
        [bufferRange.start, bufferRange.end],
        true
      );
    this.modifySelection(() => {
      const needsFlash = options.flash;
      options.flash = null;
      this.marker.setBufferRange(bufferRange, options);
      const autoscroll =
        options.autoscroll != null
          ? options.autoscroll
          : this.isLastSelection();
      if (autoscroll) this.autoscroll();
      if (needsFlash)
        this.decoration.flash('flash', this.editor.selectionFlashDuration);
    });
  }

  // Public: Returns the starting and ending buffer rows the selection is
  // highlighting.
  //
  // Returns an {Array} of two {Number}s: the starting row, and the ending row.
  getBufferRowRange() {
    const range = this.getBufferRange();
    const start = range.start.row;
    let end = range.end.row;
    if (range.end.column === 0) end = Math.max(start, end - 1);
    return [start, end];
  }

  getTailScreenPosition() {
    return this.marker.getTailScreenPosition();
  }

  getTailBufferPosition() {
    return this.marker.getTailBufferPosition();
  }

  getHeadScreenPosition() {
    return this.marker.getHeadScreenPosition();
  }

  getHeadBufferPosition() {
    return this.marker.getHeadBufferPosition();
  }

  /*
  Section: Info about the selection
  */

  // Public: Determines if the selection contains anything.
  isEmpty() {
    return this.getBufferRange().isEmpty();
  }

  // Public: Determines if the ending position of a marker is greater than the
  // starting position.
  //
  // This can happen when, for example, you highlight text "up" in a {TextBuffer}.
  isReversed() {
    return this.marker.isReversed();
  }

  // Public: Returns whether the selection is a single line or not.
  isSingleScreenLine() {
    return this.getScreenRange().isSingleLine();
  }

  // Public: Returns the text in the selection.
  getText() {
    return this.editor.buffer.getTextInRange(this.getBufferRange());
  }

  // Public: Identifies if a selection intersects with a given buffer range.
  //
  // * `bufferRange` A {Range} to check against.
  //
  // Returns a {Boolean}
  intersectsBufferRange(bufferRange) {
    return this.getBufferRange().intersectsWith(bufferRange);
  }

  intersectsScreenRowRange(startRow, endRow) {
    return this.getScreenRange().intersectsRowRange(startRow, endRow);
  }

  intersectsScreenRow(screenRow) {
    return this.getScreenRange().intersectsRow(screenRow);
  }

  // Public: Identifies if a selection intersects with another selection.
  //
  // * `otherSelection` A {Selection} to check against.
  //
  // Returns a {Boolean}
  intersectsWith(otherSelection, exclusive) {
    return this.getBufferRange().intersectsWith(
      otherSelection.getBufferRange(),
      exclusive
    );
  }

  /*
  Section: Modifying the selected range
  */

  // Public: Clears the selection, moving the marker to the head.
  //
  // * `options` (optional) {Object} with the following keys:
  //   * `autoscroll` {Boolean} indicating whether to autoscroll to the new
  //     range. Defaults to `true` if this is the most recently added selection,
  //     `false` otherwise.
  clear(options) {
    this.goalScreenRange = null;
    if (!this.retainSelection) this.marker.clearTail();
    const autoscroll =
      options && options.autoscroll != null
        ? options.autoscroll
        : this.isLastSelection();
    if (autoscroll) this.autoscroll();
    this.finalize();
  }

  // Public: Selects the text from the current cursor position to a given screen
  // position.
  //
  // * `position` An instance of {Point}, with a given `row` and `column`.
  selectToScreenPosition(position, options) {
    position = Point.fromObject(position);

    this.modifySelection(() => {
      if (this.initialScreenRange) {
        if (position.isLessThan(this.initialScreenRange.start)) {
          this.marker.setScreenRange([position, this.initialScreenRange.end], {
            reversed: true
          });
        } else {
          this.marker.setScreenRange(
            [this.initialScreenRange.start, position],
            { reversed: false }
          );
        }
      } else {
        this.cursor.setScreenPosition(position, options);
      }

      if (this.linewise) {
        this.expandOverLine(options);
      } else if (this.wordwise) {
        this.expandOverWord(options);
      }
    });
  }

  // Public: Selects the text from the current cursor position to a given buffer
  // position.
  //
  // * `position` An instance of {Point}, with a given `row` and `column`.
  selectToBufferPosition(position) {
    this.modifySelection(() => this.cursor.setBufferPosition(position));
  }

  // Public: Selects the text one position right of the cursor.
  //
  // * `columnCount` (optional) {Number} number of columns to select (default: 1)
  selectRight(columnCount) {
    this.modifySelection(() => this.cursor.moveRight(columnCount));
  }

  // Public: Selects the text one position left of the cursor.
  //
  // * `columnCount` (optional) {Number} number of columns to select (default: 1)
  selectLeft(columnCount) {
    this.modifySelection(() => this.cursor.moveLeft(columnCount));
  }

  // Public: Selects all the text one position above the cursor.
  //
  // * `rowCount` (optional) {Number} number of rows to select (default: 1)
  selectUp(rowCount) {
    this.modifySelection(() => this.cursor.moveUp(rowCount));
  }

  // Public: Selects all the text one position below the cursor.
  //
  // * `rowCount` (optional) {Number} number of rows to select (default: 1)
  selectDown(rowCount) {
    this.modifySelection(() => this.cursor.moveDown(rowCount));
  }

  // Public: Selects all the text from the current cursor position to the top of
  // the buffer.
  selectToTop() {
    this.modifySelection(() => this.cursor.moveToTop());
  }

  // Public: Selects all the text from the current cursor position to the bottom
  // of the buffer.
  selectToBottom() {
    this.modifySelection(() => this.cursor.moveToBottom());
  }

  // Public: Selects all the text in the buffer.
  selectAll() {
    this.setBufferRange(this.editor.buffer.getRange(), { autoscroll: false });
  }

  // Public: Selects all the text from the current cursor position to the
  // beginning of the line.
  selectToBeginningOfLine() {
    this.modifySelection(() => this.cursor.moveToBeginningOfLine());
  }

  // Public: Selects all the text from the current cursor position to the first
  // character of the line.
  selectToFirstCharacterOfLine() {
    this.modifySelection(() => this.cursor.moveToFirstCharacterOfLine());
  }

  // Public: Selects all the text from the current cursor position to the end of
  // the screen line.
  selectToEndOfLine() {
    this.modifySelection(() => this.cursor.moveToEndOfScreenLine());
  }

  // Public: Selects all the text from the current cursor position to the end of
  // the buffer line.
  selectToEndOfBufferLine() {
    this.modifySelection(() => this.cursor.moveToEndOfLine());
  }

  // Public: Selects all the text from the current cursor position to the
  // beginning of the word.
  selectToBeginningOfWord() {
    this.modifySelection(() => this.cursor.moveToBeginningOfWord());
  }

  // Public: Selects all the text from the current cursor position to the end of
  // the word.
  selectToEndOfWord() {
    this.modifySelection(() => this.cursor.moveToEndOfWord());
  }

  // Public: Selects all the text from the current cursor position to the
  // beginning of the next word.
  selectToBeginningOfNextWord() {
    this.modifySelection(() => this.cursor.moveToBeginningOfNextWord());
  }

  // Public: Selects text to the previous word boundary.
  selectToPreviousWordBoundary() {
    this.modifySelection(() => this.cursor.moveToPreviousWordBoundary());
  }

  // Public: Selects text to the next word boundary.
  selectToNextWordBoundary() {
    this.modifySelection(() => this.cursor.moveToNextWordBoundary());
  }

  // Public: Selects text to the previous subword boundary.
  selectToPreviousSubwordBoundary() {
    this.modifySelection(() => this.cursor.moveToPreviousSubwordBoundary());
  }

  // Public: Selects text to the next subword boundary.
  selectToNextSubwordBoundary() {
    this.modifySelection(() => this.cursor.moveToNextSubwordBoundary());
  }

  // Public: Selects all the text from the current cursor position to the
  // beginning of the next paragraph.
  selectToBeginningOfNextParagraph() {
    this.modifySelection(() => this.cursor.moveToBeginningOfNextParagraph());
  }

  // Public: Selects all the text from the current cursor position to the
  // beginning of the previous paragraph.
  selectToBeginningOfPreviousParagraph() {
    this.modifySelection(() =>
      this.cursor.moveToBeginningOfPreviousParagraph()
    );
  }

  // Public: Modifies the selection to encompass the current word.
  //
  // Returns a {Range}.
  selectWord(options = {}) {
    if (this.cursor.isSurroundedByWhitespace()) options.wordRegex = /[\t ]*/;
    if (this.cursor.isBetweenWordAndNonWord()) {
      options.includeNonWordCharacters = false;
    }

    this.setBufferRange(
      this.cursor.getCurrentWordBufferRange(options),
      options
    );
    this.wordwise = true;
    this.initialScreenRange = this.getScreenRange();
  }

  // Public: Expands the newest selection to include the entire word on which
  // the cursors rests.
  expandOverWord(options) {
    this.setBufferRange(
      this.getBufferRange().union(this.cursor.getCurrentWordBufferRange()),
      { autoscroll: false }
    );
    const autoscroll =
      options && options.autoscroll != null
        ? options.autoscroll
        : this.isLastSelection();
    if (autoscroll) this.cursor.autoscroll();
  }

  // Public: Selects an entire line in the buffer.
  //
  // * `row` The line {Number} to select (default: the row of the cursor).
  selectLine(row, options) {
    if (row != null) {
      this.setBufferRange(
        this.editor.bufferRangeForBufferRow(row, { includeNewline: true }),
        options
      );
    } else {
      const startRange = this.editor.bufferRangeForBufferRow(
        this.marker.getStartBufferPosition().row
      );
      const endRange = this.editor.bufferRangeForBufferRow(
        this.marker.getEndBufferPosition().row,
        { includeNewline: true }
      );
      this.setBufferRange(startRange.union(endRange), options);
    }

    this.linewise = true;
    this.wordwise = false;
    this.initialScreenRange = this.getScreenRange();
  }

  // Public: Expands the newest selection to include the entire line on which
  // the cursor currently rests.
  //
  // It also includes the newline character.
  expandOverLine(options) {
    const range = this.getBufferRange().union(
      this.cursor.getCurrentLineBufferRange({ includeNewline: true })
    );
    this.setBufferRange(range, { autoscroll: false });
    const autoscroll =
      options && options.autoscroll != null
        ? options.autoscroll
        : this.isLastSelection();
    if (autoscroll) this.cursor.autoscroll();
  }

  // Private: Ensure that the {TextEditor} is not marked read-only before allowing a buffer modification to occur. if
  // the editor is read-only, require an explicit opt-in option to proceed (`bypassReadOnly`) or throw an Error.
  ensureWritable(methodName, opts) {
    if (!opts.bypassReadOnly && this.editor.isReadOnly()) {
      if (atom.inDevMode() || atom.inSpecMode()) {
        const e = new Error(
          'Attempt to mutate a read-only TextEditor through a Selection'
        );
        e.detail =
          `Your package is attempting to call ${methodName} on a selection within an editor that has been marked ` +
          ' read-only. Pass {bypassReadOnly: true} to modify it anyway, or test editors with .isReadOnly() before ' +
          ' attempting modifications.';
        throw e;
      }

      return false;
    }

    return true;
  }

  /*
  Section: Modifying the selected text
  */

  // Public: Replaces text at the current selection.
  //
  // * `text` A {String} representing the text to add
  // * `options` (optional) {Object} with keys:
  //   * `select` If `true`, selects the newly added text.
  //   * `autoIndent` If `true`, indents all inserted text appropriately.
  //   * `autoIndentNewline` If `true`, indent newline appropriately.
  //   * `autoDecreaseIndent` If `true`, decreases indent level appropriately
  //     (for example, when a closing bracket is inserted).
  //   * `preserveTrailingLineIndentation` By default, when pasting multiple
  //   lines, Atom attempts to preserve the relative indent level between the
  //   first line and trailing lines, even if the indent level of the first
  //   line has changed from the copied text. If this option is `true`, this
  //   behavior is suppressed.
  //     level between the first lines and the trailing lines.
  //   * `normalizeLineEndings` (optional) {Boolean} (default: true)
  //   * `undo` *Deprecated* If `skip`, skips the undo stack for this operation. This property is deprecated. Call groupLastChanges() on the {TextBuffer} afterward instead.
  //   * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false)
  insertText(text, options = {}) {
    if (!this.ensureWritable('insertText', options)) return;

    let desiredIndentLevel, indentAdjustment;
    const oldBufferRange = this.getBufferRange();
    const wasReversed = this.isReversed();
    this.clear(options);

    let autoIndentFirstLine = false;
    const precedingText = this.editor.getTextInRange([
      [oldBufferRange.start.row, 0],
      oldBufferRange.start
    ]);
    const remainingLines = text.split('\n');
    const firstInsertedLine = remainingLines.shift();

    if (
      options.indentBasis != null &&
      !options.preserveTrailingLineIndentation
    ) {
      indentAdjustment =
        this.editor.indentLevelForLine(precedingText) - options.indentBasis;
      this.adjustIndent(remainingLines, indentAdjustment);
    }

    const textIsAutoIndentable =
      text === '\n' || text === '\r\n' || NonWhitespaceRegExp.test(text);
    if (
      options.autoIndent &&
      textIsAutoIndentable &&
      !NonWhitespaceRegExp.test(precedingText) &&
      remainingLines.length > 0
    ) {
      autoIndentFirstLine = true;
      const firstLine = precedingText + firstInsertedLine;
      const languageMode = this.editor.buffer.getLanguageMode();
      desiredIndentLevel =
        languageMode.suggestedIndentForLineAtBufferRow &&
        languageMode.suggestedIndentForLineAtBufferRow(
          oldBufferRange.start.row,
          firstLine,
          this.editor.getTabLength()
        );
      if (desiredIndentLevel != null) {
        indentAdjustment =
          desiredIndentLevel - this.editor.indentLevelForLine(firstLine);
        this.adjustIndent(remainingLines, indentAdjustment);
      }
    }

    text = firstInsertedLine;
    if (remainingLines.length > 0) text += `\n${remainingLines.join('\n')}`;

    const newBufferRange = this.editor.buffer.setTextInRange(
      oldBufferRange,
      text,
      pick(options, 'undo', 'normalizeLineEndings')
    );

    if (options.select) {
      this.setBufferRange(newBufferRange, { reversed: wasReversed });
    } else {
      if (wasReversed) this.cursor.setBufferPosition(newBufferRange.end);
    }

    if (autoIndentFirstLine) {
      this.editor.setIndentationForBufferRow(
        oldBufferRange.start.row,
        desiredIndentLevel
      );
    }

    if (options.autoIndentNewline && text === '\n') {
      this.editor.autoIndentBufferRow(newBufferRange.end.row, {
        preserveLeadingWhitespace: true,
        skipBlankLines: false
      });
    } else if (options.autoDecreaseIndent && NonWhitespaceRegExp.test(text)) {
      this.editor.autoDecreaseIndentForBufferRow(newBufferRange.start.row);
    }

    const autoscroll =
      options.autoscroll != null ? options.autoscroll : this.isLastSelection();
    if (autoscroll) this.autoscroll();

    return newBufferRange;
  }

  // Public: Removes the first character before the selection if the selection
  // is empty otherwise it deletes the selection.
  //
  // * `options` (optional) {Object}
  //   * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false)
  backspace(options = {}) {
    if (!this.ensureWritable('backspace', options)) return;
    if (this.isEmpty()) this.selectLeft();
    this.deleteSelectedText(options);
  }

  // Public: Removes the selection or, if nothing is selected, then all
  // characters from the start of the selection back to the previous word
  // boundary.
  //
  // * `options` (optional) {Object}
  //   * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false)
  deleteToPreviousWordBoundary(options = {}) {
    if (!this.ensureWritable('deleteToPreviousWordBoundary', options)) return;
    if (this.isEmpty()) this.selectToPreviousWordBoundary();
    this.deleteSelectedText(options);
  }

  // Public: Removes the selection or, if nothing is selected, then all
  // characters from the start of the selection up to the next word
  // boundary.
  //
  // * `options` (optional) {Object}
  //   * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false)
  deleteToNextWordBoundary(options = {}) {
    if (!this.ensureWritable('deleteToNextWordBoundary', options)) return;
    if (this.isEmpty()) this.selectToNextWordBoundary();
    this.deleteSelectedText(options);
  }

  // Public: Removes from the start of the selection to the beginning of the
  // current word if the selection is empty otherwise it deletes the selection.
  //
  // * `options` (optional) {Object}
  //   * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false)
  deleteToBeginningOfWord(options = {}) {
    if (!this.ensureWritable('deleteToBeginningOfWord', options)) return;
    if (this.isEmpty()) this.selectToBeginningOfWord();
    this.deleteSelectedText(options);
  }

  // Public: Removes from the beginning of the line which the selection begins on
  // all the way through to the end of the selection.
  //
  // * `options` (optional) {Object}
  //   * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false)
  deleteToBeginningOfLine(options = {}) {
    if (!this.ensureWritable('deleteToBeginningOfLine', options)) return;
    if (this.isEmpty() && this.cursor.isAtBeginningOfLine()) {
      this.selectLeft();
    } else {
      this.selectToBeginningOfLine();
    }
    this.deleteSelectedText(options);
  }

  // Public: Removes the selection or the next character after the start of the
  // selection if the selection is empty.
  //
  // * `options` (optional) {Object}
  //   * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false)
  delete(options = {}) {
    if (!this.ensureWritable('delete', options)) return;
    if (this.isEmpty()) this.selectRight();
    this.deleteSelectedText(options);
  }

  // Public: If the selection is empty, removes all text from the cursor to the
  // end of the line. If the cursor is already at the end of the line, it
  // removes the following newline. If the selection isn't empty, only deletes
  // the contents of the selection.
  //
  // * `options` (optional) {Object}
  //   * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false)
  deleteToEndOfLine(options = {}) {
    if (!this.ensureWritable('deleteToEndOfLine', options)) return;
    if (this.isEmpty()) {
      if (this.cursor.isAtEndOfLine()) {
        this.delete(options);
        return;
      }
      this.selectToEndOfLine();
    }
    this.deleteSelectedText(options);
  }

  // Public: Removes the selection or all characters from the start of the
  // selection to the end of the current word if nothing is selected.
  //
  // * `options` (optional) {Object}
  //   * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false)
  deleteToEndOfWord(options = {}) {
    if (!this.ensureWritable('deleteToEndOfWord', options)) return;
    if (this.isEmpty()) this.selectToEndOfWord();
    this.deleteSelectedText(options);
  }

  // Public: Removes the selection or all characters from the start of the
  // selection to the end of the current word if nothing is selected.
  //
  // * `options` (optional) {Object}
  //   * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false)
  deleteToBeginningOfSubword(options = {}) {
    if (!this.ensureWritable('deleteToBeginningOfSubword', options)) return;
    if (this.isEmpty()) this.selectToPreviousSubwordBoundary();
    this.deleteSelectedText(options);
  }

  // Public: Removes the selection or all characters from the start of the
  // selection to the end of the current word if nothing is selected.
  //
  // * `options` (optional) {Object}
  //   * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false)
  deleteToEndOfSubword(options = {}) {
    if (!this.ensureWritable('deleteToEndOfSubword', options)) return;
    if (this.isEmpty()) this.selectToNextSubwordBoundary();
    this.deleteSelectedText(options);
  }

  // Public: Removes only the selected text.
  //
  // * `options` (optional) {Object}
  //   * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false)
  deleteSelectedText(options = {}) {
    if (!this.ensureWritable('deleteSelectedText', options)) return;
    const bufferRange = this.getBufferRange();
    if (!bufferRange.isEmpty()) this.editor.buffer.delete(bufferRange);
    if (this.cursor) this.cursor.setBufferPosition(bufferRange.start);
  }

  // Public: Removes the line at the beginning of the selection if the selection
  // is empty unless the selection spans multiple lines in which case all lines
  // are removed.
  //
  // * `options` (optional) {Object}
  //   * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false)
  deleteLine(options = {}) {
    if (!this.ensureWritable('deleteLine', options)) return;
    const range = this.getBufferRange();
    if (range.isEmpty()) {
      const start = this.cursor.getScreenRow();
      const range = this.editor.bufferRowsForScreenRows(start, start + 1);
      if (range[1] > range[0]) {
        this.editor.buffer.deleteRows(range[0], range[1] - 1);
      } else {
        this.editor.buffer.deleteRow(range[0]);
      }
    } else {
      const start = range.start.row;
      let end = range.end.row;
      if (end !== this.editor.buffer.getLastRow() && range.end.column === 0)
        end--;
      this.editor.buffer.deleteRows(start, end);
    }
    this.cursor.setBufferPosition({
      row: this.cursor.getBufferRow(),
      column: range.start.column
    });
  }

  // Public: Joins the current line with the one below it. Lines will
  // be separated by a single space.
  //
  // If there selection spans more than one line, all the lines are joined together.
  //
  // * `options` (optional) {Object}
  //   * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false)
  joinLines(options = {}) {
    if (!this.ensureWritable('joinLines', options)) return;
    let joinMarker;
    const selectedRange = this.getBufferRange();
    if (selectedRange.isEmpty()) {
      if (selectedRange.start.row === this.editor.buffer.getLastRow()) return;
    } else {
      joinMarker = this.editor.markBufferRange(selectedRange, {
        invalidate: 'never'
      });
    }

    const rowCount = Math.max(1, selectedRange.getRowCount() - 1);
    for (let i = 0; i < rowCount; i++) {
      this.cursor.setBufferPosition([selectedRange.start.row]);
      this.cursor.moveToEndOfLine();

      // Remove trailing whitespace from the current line
      const scanRange = this.cursor.getCurrentLineBufferRange();
      let trailingWhitespaceRange = null;
      this.editor.scanInBufferRange(/[ \t]+$/, scanRange, ({ range }) => {
        trailingWhitespaceRange = range;
      });
      if (trailingWhitespaceRange) {
        this.setBufferRange(trailingWhitespaceRange);
        this.deleteSelectedText(options);
      }

      const currentRow = selectedRange.start.row;
      const nextRow = currentRow + 1;
      const insertSpace =
        nextRow <= this.editor.buffer.getLastRow() &&
        this.editor.buffer.lineLengthForRow(nextRow) > 0 &&
        this.editor.buffer.lineLengthForRow(currentRow) > 0;
      if (insertSpace) this.insertText(' ', options);

      this.cursor.moveToEndOfLine();

      // Remove leading whitespace from the line below
      this.modifySelection(() => {
        this.cursor.moveRight();
        this.cursor.moveToFirstCharacterOfLine();
      });
      this.deleteSelectedText(options);

      if (insertSpace) this.cursor.moveLeft();
    }

    if (joinMarker) {
      const newSelectedRange = joinMarker.getBufferRange();
      this.setBufferRange(newSelectedRange);
      joinMarker.destroy();
    }
  }

  // Public: Removes one level of indent from the currently selected rows.
  //
  // * `options` (optional) {Object}
  //   * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false)
  outdentSelectedRows(options = {}) {
    if (!this.ensureWritable('outdentSelectedRows', options)) return;
    const [start, end] = this.getBufferRowRange();
    const { buffer } = this.editor;
    const leadingTabRegex = new RegExp(
      `^( {1,${this.editor.getTabLength()}}|\t)`
    );
    for (let row = start; row <= end; row++) {
      const match = buffer.lineForRow(row).match(leadingTabRegex);
      if (match && match[0].length > 0) {
        buffer.delete([[row, 0], [row, match[0].length]]);
      }
    }
  }

  // Public: Sets the indentation level of all selected rows to values suggested
  // by the relevant grammars.
  //
  // * `options` (optional) {Object}
  //   * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false)
  autoIndentSelectedRows(options = {}) {
    if (!this.ensureWritable('autoIndentSelectedRows', options)) return;
    const [start, end] = this.getBufferRowRange();
    return this.editor.autoIndentBufferRows(start, end);
  }

  // Public: Wraps the selected lines in comments if they aren't currently part
  // of a comment.
  //
  // Removes the comment if they are currently wrapped in a comment.
  //
  // * `options` (optional) {Object}
  //   * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false)
  toggleLineComments(options = {}) {
    if (!this.ensureWritable('toggleLineComments', options)) return;
    let bufferRowRange = this.getBufferRowRange() || [null, null];
    this.editor.toggleLineCommentsForBufferRows(...bufferRowRange, {
      correctSelection: true,
      selection: this
    });
  }

  // Public: Cuts the selection until the end of the screen line.
  //
  // * `maintainClipboard` {Boolean}
  // * `options` (optional) {Object}
  //   * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false)
  cutToEndOfLine(maintainClipboard, options = {}) {
    if (!this.ensureWritable('cutToEndOfLine', options)) return;
    if (this.isEmpty()) this.selectToEndOfLine();
    return this.cut(maintainClipboard, false, options.bypassReadOnly);
  }

  // Public: Cuts the selection until the end of the buffer line.
  //
  // * `maintainClipboard` {Boolean}
  // * `options` (optional) {Object}
  //   * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false)
  cutToEndOfBufferLine(maintainClipboard, options = {}) {
    if (!this.ensureWritable('cutToEndOfBufferLine', options)) return;
    if (this.isEmpty()) this.selectToEndOfBufferLine();
    this.cut(maintainClipboard, false, options.bypassReadOnly);
  }

  // Public: Copies the selection to the clipboard and then deletes it.
  //
  // * `maintainClipboard` {Boolean} (default: false) See {::copy}
  // * `fullLine` {Boolean} (default: false) See {::copy}
  // * `bypassReadOnly` {Boolean} (default: false) Must be `true` to modify text within a read-only editor.
  cut(maintainClipboard = false, fullLine = false, bypassReadOnly = false) {
    if (!this.ensureWritable('cut', { bypassReadOnly })) return;
    this.copy(maintainClipboard, fullLine);
    this.delete({ bypassReadOnly });
  }

  // Public: Copies the current selection to the clipboard.
  //
  // * `maintainClipboard` {Boolean} if `true`, a specific metadata property
  //   is created to store each content copied to the clipboard. The clipboard
  //   `text` still contains the concatenation of the clipboard with the
  //   current selection. (default: false)
  // * `fullLine` {Boolean} if `true`, the copied text will always be pasted
  //   at the beginning of the line containing the cursor, regardless of the
  //   cursor's horizontal position. (default: false)
  copy(maintainClipboard = false, fullLine = false) {
    if (this.isEmpty()) return;
    const { start, end } = this.getBufferRange();
    const selectionText = this.editor.getTextInRange([start, end]);
    const precedingText = this.editor.getTextInRange([[start.row, 0], start]);
    const startLevel = this.editor.indentLevelForLine(precedingText);

    if (maintainClipboard) {
      let {
        text: clipboardText,
        metadata
      } = this.editor.constructor.clipboard.readWithMetadata();
      if (!metadata) metadata = {};
      if (!metadata.selections) {
        metadata.selections = [
          {
            text: clipboardText,
            indentBasis: metadata.indentBasis,
            fullLine: metadata.fullLine
          }
        ];
      }
      metadata.selections.push({
        text: selectionText,
        indentBasis: startLevel,
        fullLine
      });
      this.editor.constructor.clipboard.write(
        [clipboardText, selectionText].join('\n'),
        metadata
      );
    } else {
      this.editor.constructor.clipboard.write(selectionText, {
        indentBasis: startLevel,
        fullLine
      });
    }
  }

  // Public: Creates a fold containing the current selection.
  fold() {
    const range = this.getBufferRange();
    if (!range.isEmpty()) {
      this.editor.foldBufferRange(range);
      this.cursor.setBufferPosition(range.end);
    }
  }

  // Private: Increase the indentation level of the given text by given number
  // of levels. Leaves the first line unchanged.
  adjustIndent(lines, indentAdjustment) {
    for (let i = 0; i < lines.length; i++) {
      const line = lines[i];
      if (indentAdjustment === 0 || line === '') {
        continue;
      } else if (indentAdjustment > 0) {
        lines[i] = this.editor.buildIndentString(indentAdjustment) + line;
      } else {
        const currentIndentLevel = this.editor.indentLevelForLine(lines[i]);
        const indentLevel = Math.max(0, currentIndentLevel + indentAdjustment);
        lines[i] = line.replace(
          /^[\t ]+/,
          this.editor.buildIndentString(indentLevel)
        );
      }
    }
  }

  // Indent the current line(s).
  //
  // If the selection is empty, indents the current line if the cursor precedes
  // non-whitespace characters, and otherwise inserts a tab. If the selection is
  // non empty, calls {::indentSelectedRows}.
  //
  // * `options` (optional) {Object} with the keys:
  //   * `autoIndent` If `true`, the line is indented to an automatically-inferred
  //     level. Otherwise, {TextEditor::getTabText} is inserted.
  //   * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false)
  indent({ autoIndent, bypassReadOnly } = {}) {
    if (!this.ensureWritable('indent', { bypassReadOnly })) return;
    const { row } = this.cursor.getBufferPosition();

    if (this.isEmpty()) {
      this.cursor.skipLeadingWhitespace();
      const desiredIndent = this.editor.suggestedIndentForBufferRow(row);
      let delta = desiredIndent - this.cursor.getIndentLevel();

      if (autoIndent && delta > 0) {
        if (!this.editor.getSoftTabs()) delta = Math.max(delta, 1);
        this.insertText(this.editor.buildIndentString(delta), {
          bypassReadOnly
        });
      } else {
        this.insertText(
          this.editor.buildIndentString(1, this.cursor.getBufferColumn()),
          { bypassReadOnly }
        );
      }
    } else {
      this.indentSelectedRows({ bypassReadOnly });
    }
  }

  // Public: If the selection spans multiple rows, indent all of them.
  //
  // * `options` (optional) {Object} with the keys:
  //   * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false)
  indentSelectedRows(options = {}) {
    if (!this.ensureWritable('indentSelectedRows', options)) return;
    const [start, end] = this.getBufferRowRange();
    for (let row = start; row <= end; row++) {
      if (this.editor.buffer.lineLengthForRow(row) !== 0) {
        this.editor.buffer.insert([row, 0], this.editor.getTabText());
      }
    }
  }

  /*
  Section: Managing multiple selections
  */

  // Public: Moves the selection down one row.
  addSelectionBelow() {
    const range = this.getGoalScreenRange().copy();
    const nextRow = range.end.row + 1;

    for (
      let row = nextRow, end = this.editor.getLastScreenRow();
      row <= end;
      row++
    ) {
      range.start.row = row;
      range.end.row = row;
      const clippedRange = this.editor.clipScreenRange(range, {
        skipSoftWrapIndentation: true
      });

      if (range.isEmpty()) {
        if (range.end.column > 0 && clippedRange.end.column === 0) continue;
      } else {
        if (clippedRange.isEmpty()) continue;
      }

      const containingSelections = this.editor.selectionsMarkerLayer.findMarkers(
        { containsScreenRange: clippedRange }
      );
      if (containingSelections.length === 0) {
        const selection = this.editor.addSelectionForScreenRange(clippedRange);
        selection.setGoalScreenRange(range);
      }

      break;
    }
  }

  // Public: Moves the selection up one row.
  addSelectionAbove() {
    const range = this.getGoalScreenRange().copy();
    const previousRow = range.end.row - 1;

    for (let row = previousRow; row >= 0; row--) {
      range.start.row = row;
      range.end.row = row;
      const clippedRange = this.editor.clipScreenRange(range, {
        skipSoftWrapIndentation: true
      });

      if (range.isEmpty()) {
        if (range.end.column > 0 && clippedRange.end.column === 0) continue;
      } else {
        if (clippedRange.isEmpty()) continue;
      }

      const containingSelections = this.editor.selectionsMarkerLayer.findMarkers(
        { containsScreenRange: clippedRange }
      );
      if (containingSelections.length === 0) {
        const selection = this.editor.addSelectionForScreenRange(clippedRange);
        selection.setGoalScreenRange(range);
      }

      break;
    }
  }

  // Public: Combines the given selection into this selection and then destroys
  // the given selection.
  //
  // * `otherSelection` A {Selection} to merge with.
  // * `options` (optional) {Object} options matching those found in {::setBufferRange}.
  merge(otherSelection, options = {}) {
    const myGoalScreenRange = this.getGoalScreenRange();
    const otherGoalScreenRange = otherSelection.getGoalScreenRange();

    if (myGoalScreenRange && otherGoalScreenRange) {
      options.goalScreenRange = myGoalScreenRange.union(otherGoalScreenRange);
    } else {
      options.goalScreenRange = myGoalScreenRange || otherGoalScreenRange;
    }

    const bufferRange = this.getBufferRange().union(
      otherSelection.getBufferRange()
    );
    this.setBufferRange(
      bufferRange,
      Object.assign({ autoscroll: false }, options)
    );
    otherSelection.destroy();
  }

  /*
  Section: Comparing to other selections
  */

  // Public: Compare this selection's buffer range to another selection's buffer
  // range.
  //
  // See {Range::compare} for more details.
  //
  // * `otherSelection` A {Selection} to compare against
  compare(otherSelection) {
    return this.marker.compare(otherSelection.marker);
  }

  /*
  Section: Private Utilities
  */

  setGoalScreenRange(range) {
    this.goalScreenRange = Range.fromObject(range);
  }

  getGoalScreenRange() {
    return this.goalScreenRange || this.getScreenRange();
  }

  markerDidChange(e) {
    const {
      oldHeadBufferPosition,
      oldTailBufferPosition,
      newHeadBufferPosition
    } = e;
    const {
      oldHeadScreenPosition,
      oldTailScreenPosition,
      newHeadScreenPosition
    } = e;
    const { textChanged } = e;

    if (!oldHeadScreenPosition.isEqual(newHeadScreenPosition)) {
      this.cursor.goalColumn = null;
      const cursorMovedEvent = {
        oldBufferPosition: oldHeadBufferPosition,
        oldScreenPosition: oldHeadScreenPosition,
        newBufferPosition: newHeadBufferPosition,
        newScreenPosition: newHeadScreenPosition,
        textChanged,
        cursor: this.cursor
      };
      this.cursor.emitter.emit('did-change-position', cursorMovedEvent);
      this.editor.cursorMoved(cursorMovedEvent);
    }

    const rangeChangedEvent = {
      oldBufferRange: new Range(oldHeadBufferPosition, oldTailBufferPosition),
      oldScreenRange: new Range(oldHeadScreenPosition, oldTailScreenPosition),
      newBufferRange: this.getBufferRange(),
      newScreenRange: this.getScreenRange(),
      selection: this
    };
    this.emitter.emit('did-change-range', rangeChangedEvent);
    this.editor.selectionRangeChanged(rangeChangedEvent);
  }

  markerDidDestroy() {
    if (this.editor.isDestroyed()) return;

    this.destroyed = true;
    this.cursor.destroyed = true;

    this.editor.removeSelection(this);

    this.cursor.emitter.emit('did-destroy');
    this.emitter.emit('did-destroy');

    this.cursor.emitter.dispose();
    this.emitter.dispose();
  }

  finalize() {
    if (
      !this.initialScreenRange ||
      !this.initialScreenRange.isEqual(this.getScreenRange())
    ) {
      this.initialScreenRange = null;
    }
    if (this.isEmpty()) {
      this.wordwise = false;
      this.linewise = false;
    }
  }

  autoscroll(options) {
    if (this.marker.hasTail()) {
      this.editor.scrollToScreenRange(
        this.getScreenRange(),
        Object.assign({ reversed: this.isReversed() }, options)
      );
    } else {
      this.cursor.autoscroll(options);
    }
  }

  clearAutoscroll() {}

  modifySelection(fn) {
    this.retainSelection = true;
    this.plantTail();
    fn();
    this.retainSelection = false;
  }

  // Sets the marker's tail to the same position as the marker's head.
  //
  // This only works if there isn't already a tail position.
  //
  // Returns a {Point} representing the new tail position.
  plantTail() {
    this.marker.plantTail();
  }
};