text-buffer
Version:
A container for large mutable strings with annotated regions
1,379 lines (1,242 loc) • 92.1 kB
JavaScript
const {Emitter, CompositeDisposable} = require('event-kit')
const {File} = require('pathwatcher')
const diff = require('diff')
const _ = require('underscore-plus')
const path = require('path')
const crypto = require('crypto')
const mkdirp = require('mkdirp')
const superstring = require('superstring')
const NativeTextBuffer = superstring.TextBuffer
const Point = require('./point')
const Range = require('./range')
const DefaultHistoryProvider = require('./default-history-provider')
const NullLanguageMode = require('./null-language-mode')
const Marker = require('./marker')
const MarkerLayer = require('./marker-layer')
const DisplayLayer = require('./display-layer')
const {spliceArray, newlineRegex, patchFromChanges, normalizePatchChanges, extentForText, debounce} = require('./helpers')
const {traverse, traversal} = require('./point-helpers')
const Grim = require('grim')
// Extended: A mutable text container with undo/redo support and the ability to
// annotate logical regions in the text.
//
// ## Observing Changes
//
// You can observe changes in a {TextBuffer} using methods like {::onDidChange},
// {::onDidStopChanging}, and {::getChangesSinceCheckpoint}. These methods report
// aggregated buffer updates as arrays of change objects containing the following
// fields: `oldRange`, `newRange`, `oldText`, and `newText`. The `oldText`,
// `newText`, and `newRange` fields are self-explanatory, but the interepretation
// of `oldRange` is more nuanced:
//
// The reported `oldRange` is the range of the replaced text in the original
// contents of the buffer *irrespective of the spatial impact of any other
// reported change*. So, for example, if you wanted to apply all the changes made
// in a transaction to a clone of the observed buffer, the easiest approach would
// be to apply the changes in reverse:
//
// ```js
// buffer1.onDidChange(({changes}) => {
// for (const {oldRange, newText} of changes.reverse()) {
// buffer2.setTextInRange(oldRange, newText)
// }
// })
// ```
//
// If you needed to apply the changes in the forwards order, you would need to
// incorporate the impact of preceding changes into the range passed to
// {::setTextInRange}, as follows:
//
// ```js
// buffer1.onDidChange(({changes}) => {
// for (const {oldRange, newRange, newText} of changes) {
// const rangeToReplace = Range(
// newRange.start,
// newRange.start.traverse(oldRange.getExtent())
// )
// buffer2.setTextInRange(rangeToReplace, newText)
// }
// })
// ```
class TextBuffer {
/*
Section: Construction
*/
// Public: Create a new buffer with the given params.
//
// * `params` {Object} or {String} of text
// * `text` The initial {String} text of the buffer.
// * `shouldDestroyOnFileDelete` A {Function} that returns a {Boolean}
// indicating whether the buffer should be destroyed if its file is
// deleted.
constructor (params) {
if (params == null) params = {}
this.refcount = 0
this.conflict = false
this.file = null
this.fileSubscriptions = null
this.stoppedChangingTimeout = null
this.emitter = new Emitter()
this.changesSinceLastStoppedChangingEvent = []
this.changesSinceLastDidChangeTextEvent = []
this.id = crypto.randomBytes(16).toString('hex')
this.buffer = new NativeTextBuffer(typeof params === 'string' ? params : params.text)
this.debouncedEmitDidStopChangingEvent = debounce(this.emitDidStopChangingEvent.bind(this), this.stoppedChangingDelay)
this.maxUndoEntries = params.maxUndoEntries != null ? params.maxUndoEntries : this.defaultMaxUndoEntries
this.setHistoryProvider(new DefaultHistoryProvider(this))
this.languageMode = new NullLanguageMode()
this.nextMarkerLayerId = 0
this.nextDisplayLayerId = 0
this.defaultMarkerLayer = new MarkerLayer(this, String(this.nextMarkerLayerId++))
this.displayLayers = {}
this.markerLayers = {}
this.markerLayers[this.defaultMarkerLayer.id] = this.defaultMarkerLayer
this.markerLayersWithPendingUpdateEvents = new Set()
this.selectionsMarkerLayerIds = new Set()
this.nextMarkerId = 1
this.outstandingSaveCount = 0
this.loadCount = 0
this.cachedHasAstral = null
this._emittedWillChangeEvent = false
this.setEncoding(params.encoding)
this.setPreferredLineEnding(params.preferredLineEnding)
this.loaded = false
this.destroyed = false
this.transactCallDepth = 0
this.digestWhenLastPersisted = false
this.shouldDestroyOnFileDelete = params.shouldDestroyOnFileDelete || (() => false)
if (params.filePath) {
this.setPath(params.filePath)
if (params.load) {
Grim.deprecate(
'The `load` option to the TextBuffer constructor is deprecated. ' +
'Get a loaded buffer using TextBuffer.load(filePath) instead.'
)
this.load({internal: true})
}
}
}
toString () {
return `<TextBuffer ${this.id}>`
}
// Public: Create a new buffer backed by the given file path.
//
// * `source` Either a {String} path to a local file or (experimentally) a file
// {Object} as described by the {::setFile} method.
// * `params` An {Object} with the following properties:
// * `encoding` (optional) {String} The file's encoding.
// * `shouldDestroyOnFileDelete` (optional) A {Function} that returns a
// {Boolean} indicating whether the buffer should be destroyed if its file
// is deleted.
//
// Returns a {Promise} that resolves with a {TextBuffer} instance.
static load (source, params) {
const buffer = new TextBuffer(params)
if (typeof source === 'string') {
buffer.setPath(source)
} else {
buffer.setFile(source)
}
return buffer
.load({clearHistory: true, internal: true, mustExist: params && params.mustExist})
.then(() => buffer)
.catch(err => {
buffer.destroy()
throw err
})
}
// Public: Create a new buffer backed by the given file path. For better
// performance, use {TextBuffer.load} instead.
//
// * `filePath` The {String} file path.
// * `params` An {Object} with the following properties:
// * `encoding` (optional) {String} The file's encoding.
// * `shouldDestroyOnFileDelete` (optional) A {Function} that returns a
// {Boolean} indicating whether the buffer should be destroyed if its file
// is deleted.
//
// Returns a {TextBuffer} instance.
static loadSync (filePath, params) {
const buffer = new TextBuffer(params)
buffer.setPath(filePath)
try {
buffer.loadSync({internal: true, mustExist: params && params.mustExist})
} catch (e) {
buffer.destroy()
throw e
}
return buffer
}
// Public: Restore a {TextBuffer} based on an earlier state created using
// the {TextBuffer::serialize} method.
//
// * `params` An {Object} returned from {TextBuffer::serialize}
//
// Returns a {Promise} that resolves with a {TextBuffer} instance.
static async deserialize (params) {
if (params.version && params.version !== TextBuffer.version) return
delete params.load
let buffer
if (params.filePath) {
buffer = await TextBuffer.load(params.filePath, params)
if (buffer.digestWhenLastPersisted === params.digestWhenLastPersisted) {
buffer.buffer.deserializeChanges(params.outstandingChanges)
} else {
params.history = {}
}
} else {
buffer = new TextBuffer(params)
}
buffer.id = params.id
buffer.preferredLineEnding = params.preferredLineEnding
buffer.nextMarkerId = params.nextMarkerId
buffer.nextMarkerLayerId = params.nextMarkerLayerId
buffer.nextDisplayLayerId = params.nextDisplayLayerId
buffer.historyProvider.deserialize(params.history, buffer)
for (const layerId in params.markerLayers) {
const layerState = params.markerLayers[layerId]
let layer
if (layerId === params.defaultMarkerLayerId) {
buffer.defaultMarkerLayer.id = layerId
buffer.defaultMarkerLayer.deserialize(layerState)
layer = buffer.defaultMarkerLayer
} else {
layer = MarkerLayer.deserialize(buffer, layerState)
}
buffer.markerLayers[layerId] = layer
}
for (const layerId in params.displayLayers) {
const layerState = params.displayLayers[layerId]
buffer.displayLayers[layerId] = DisplayLayer.deserialize(buffer, layerState)
}
return buffer
}
// Returns a {String} representing a unique identifier for this {TextBuffer}.
getId () {
return this.id
}
serialize (options) {
if (options == null) options = {}
if (options.markerLayers == null) options.markerLayers = true
if (options.history == null) options.history = true
const markerLayers = {}
if (options.markerLayers) {
for (const id in this.markerLayers) {
const layer = this.markerLayers[id]
if (layer.persistent) {
markerLayers[id] = layer.serialize()
}
}
}
const displayLayers = {}
for (const id in this.displayLayers) {
const layer = this.displayLayers[id]
displayLayers[id] = layer.serialize()
}
let history = {}
if (options.history) {
history = this.historyProvider.serialize(options)
}
const result = {
id: this.getId(),
version: TextBuffer.version,
defaultMarkerLayerId: this.defaultMarkerLayer.id,
markerLayers,
displayLayers,
nextMarkerLayerId: this.nextMarkerLayerId,
nextDisplayLayerId: this.nextDisplayLayerId,
history,
encoding: this.getEncoding(),
preferredLineEnding: this.preferredLineEnding,
nextMarkerId: this.nextMarkerId
}
const filePath = this.getPath()
if (filePath) {
if (this.baseTextDigestCache == null) this.baseTextDigestCache = this.buffer.baseTextDigest()
result.filePath = filePath
result.digestWhenLastPersisted = this.digestWhenLastPersisted
result.outstandingChanges = this.buffer.serializeChanges()
} else {
result.text = this.getText()
}
return result
}
/*
Section: Event Subscription
*/
// Public: Invoke the given callback synchronously _before_ the content of the
// buffer changes.
//
// Because observers are invoked synchronously, it's important not to perform
// any expensive operations via this method.
//
// * `callback` {Function} to be called when the buffer changes.
//
// Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
onWillChange (callback) {
return this.emitter.on('will-change', callback)
}
// Public: Invoke the given callback synchronously when a transaction finishes
// with a list of all the changes in the transaction.
//
// * `callback` {Function} to be called when a transaction in which textual
// changes occurred is completed.
// * `event` {Object} with the following keys:
// * `oldRange` The smallest combined {Range} containing all of the old text.
// * `newRange` The smallest combined {Range} containing all of the new text.
// * `changes` {Array} of {Object}s summarizing the aggregated changes
// that occurred during the transaction. See *Working With Aggregated
// Changes* in the description of the {TextBuffer} class for details.
// * `oldRange` The {Range} of the deleted text in the contents of the
// buffer as it existed *before* the batch of changes reported by this
// event.
// * `newRange`: The {Range} of the inserted text in the current contents
// of the buffer.
// * `oldText`: A {String} representing the deleted text.
// * `newText`: A {String} representing the inserted text.
//
// Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
onDidChange (callback) {
return this.emitter.on('did-change-text', callback)
}
// Public: This is now identical to {::onDidChange}.
onDidChangeText (callback) {
return this.onDidChange(callback)
}
// Public: Invoke the given callback asynchronously following one or more
// changes after {::getStoppedChangingDelay} milliseconds elapse without an
// additional change.
//
// This method can be used to perform potentially expensive operations that
// don't need to be performed synchronously. If you need to run your callback
// synchronously, use {::onDidChange} instead.
//
// * `callback` {Function} to be called when the buffer stops changing.
// * `event` {Object} with the following keys:
// * `changes` An {Array} containing {Object}s summarizing the aggregated
// changes. See *Working With Aggregated Changes* in the description of
// the {TextBuffer} class for details.
// * `oldRange` The {Range} of the deleted text in the contents of the
// buffer as it existed *before* the batch of changes reported by this
// event.
// * `newRange`: The {Range} of the inserted text in the current contents
// of the buffer.
// * `oldText`: A {String} representing the deleted text.
// * `newText`: A {String} representing the inserted text.
//
// Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
onDidStopChanging (callback) {
return this.emitter.on('did-stop-changing', callback)
}
// Public: Invoke the given callback when the in-memory contents of the
// buffer become in conflict with the contents of the file on disk.
//
// * `callback` {Function} to be called when the buffer enters conflict.
//
// Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
onDidConflict (callback) {
return this.emitter.on('did-conflict', callback)
}
// Public: Invoke the given callback if the value of {::isModified} changes.
//
// * `callback` {Function} to be called when {::isModified} changes.
// * `modified` {Boolean} indicating whether the buffer is modified.
//
// Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
onDidChangeModified (callback) {
return this.emitter.on('did-change-modified', callback)
}
// Public: Invoke the given callback when all marker `::onDidChange`
// observers have been notified following a change to the buffer.
//
// The order of events following a buffer change is as follows:
//
// * The text of the buffer is changed
// * All markers are updated accordingly, but their `::onDidChange` observers
// are not notified.
// * `TextBuffer::onDidChange` observers are notified.
// * `Marker::onDidChange` observers are notified.
// * `TextBuffer::onDidUpdateMarkers` observers are notified.
//
// Basically, this method gives you a way to take action after both a buffer
// change and all associated marker changes.
//
// * `callback` {Function} to be called after markers are updated.
//
// Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
onDidUpdateMarkers (callback) {
return this.emitter.on('did-update-markers', callback)
}
// Public: Invoke the given callback when a marker is created.
//
// * `callback` {Function} to be called when a marker is created.
// * `marker` {Marker} that was created.
//
// Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
onDidCreateMarker (callback) {
return this.emitter.on('did-create-marker', callback)
}
// Public: Invoke the given callback when the value of {::getPath} changes.
//
// * `callback` {Function} to be called when the path changes.
// * `path` {String} representing the buffer's current path on disk.
//
// Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
onDidChangePath (callback) {
return this.emitter.on('did-change-path', callback)
}
// Public: Invoke the given callback when the value of {::getEncoding} changes.
//
// * `callback` {Function} to be called when the encoding changes.
// * `encoding` {String} character set encoding of the buffer.
//
// Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
onDidChangeEncoding (callback) {
return this.emitter.on('did-change-encoding', callback)
}
// Public: Invoke the given callback before the buffer is saved to disk.
//
// * `callback` {Function} to be called before the buffer is saved. If this function returns
// a {Promise}, then the buffer will not be saved until the promise resolves.
//
// Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
onWillSave (callback) {
return this.emitter.on('will-save', callback)
}
// Public: Invoke the given callback after the buffer is saved to disk.
//
// * `callback` {Function} to be called after the buffer is saved.
// * `event` {Object} with the following keys:
// * `path` The path to which the buffer was saved.
//
// Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
onDidSave (callback) {
return this.emitter.on('did-save', callback)
}
// Public: Invoke the given callback after the file backing the buffer is
// deleted.
//
// * `callback` {Function} to be called after the buffer is deleted.
//
// Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
onDidDelete (callback) {
return this.emitter.on('did-delete', callback)
}
// Public: Invoke the given callback before the buffer is reloaded from the
// contents of its file on disk.
//
// * `callback` {Function} to be called before the buffer is reloaded.
//
// Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
onWillReload (callback) {
return this.emitter.on('will-reload', callback)
}
// Public: Invoke the given callback after the buffer is reloaded from the
// contents of its file on disk.
//
// * `callback` {Function} to be called after the buffer is reloaded.
//
// Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
onDidReload (callback) {
return this.emitter.on('did-reload', callback)
}
// Public: Invoke the given callback when the buffer is destroyed.
//
// * `callback` {Function} to be called when the buffer is destroyed.
//
// Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
onDidDestroy (callback) {
return this.emitter.on('did-destroy', callback)
}
// Public: Invoke the given callback when there is an error in watching the
// file.
//
// * `callback` {Function} callback
// * `errorObject` {Object}
// * `error` {Object} the error object
// * `handle` {Function} call this to indicate you have handled the error.
// The error will not be thrown if this function is called.
//
// Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
onWillThrowWatchError (callback) {
return this.emitter.on('will-throw-watch-error', callback)
}
// Public: Get the number of milliseconds that will elapse without a change
// before {::onDidStopChanging} observers are invoked following a change.
//
// Returns a {Number}.
getStoppedChangingDelay () {
return this.stoppedChangingDelay
}
/*
Section: File Details
*/
// 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 () {
if (this.file) {
return !this.file.existsSync() || this.buffer.isModified()
} else {
return this.buffer.getLength() > 0
}
}
// Public: Determine if the in-memory contents of the buffer conflict with the
// on-disk contents of its associated file.
//
// Returns a {Boolean}.
isInConflict () {
return this.isModified() && this.fileHasChangedSinceLastLoad
}
// Public: Get the path of the associated file.
//
// Returns a {String}.
getPath () {
return this.file ? this.file.getPath() : undefined
}
// Public: Set the path for the buffer's associated file.
//
// * `filePath` A {String} representing the new file path
setPath (filePath) {
if (filePath === this.getPath()) return
return this.setFile(filePath && new File(filePath))
}
// Experimental: Set a custom {File} object as the buffer's backing store.
//
// * `file` An {Object} with the following properties:
// * `getPath` A {Function} that returns the {String} path to the file.
// * `createReadStream` A {Function} that returns a `Readable` stream
// that can be used to load the file's content.
// * `createWriteStream` A {Function} that returns a `Writable` stream
// that can be used to save content to the file.
// * `existsSync` A {Function} that returns a {Boolean}, true if the file exists, false otherwise.
// * `onDidChange` (optional) A {Function} that invokes its callback argument
// when the file changes. The method should return a {Disposable} that
// can be used to prevent further calls to the callback.
// * `onDidDelete` (optional) A {Function} that invokes its callback argument
// when the file is deleted. The method should return a {Disposable} that
// can be used to prevent further calls to the callback.
// * `onDidRename` (optional) A {Function} that invokes its callback argument
// when the file is renamed. The method should return a {Disposable} that
// can be used to prevent further calls to the callback.
setFile (file) {
if (!this.file && !file) return
if (file && file.getPath() === this.getPath()) return
this.file = file
if (this.file) {
if (typeof this.file.setEncoding === 'function') {
this.file.setEncoding(this.getEncoding())
}
this.subscribeToFile()
}
this.emitter.emit('did-change-path', this.getPath())
}
// Public: Sets the character set encoding for this buffer.
//
// * `encoding` The {String} encoding to use (default: 'utf8').
setEncoding (encoding = 'utf8') {
if (encoding === this.getEncoding()) return
this.encoding = encoding
if (this.file) {
if (typeof this.file.setEncoding === 'function') {
this.file.setEncoding(encoding)
}
this.emitter.emit('did-change-encoding', encoding)
if (!this.isModified()) {
this.load({clearHistory: true, internal: true})
}
} else {
this.emitter.emit('did-change-encoding', encoding)
}
}
// Public: Returns the {String} encoding of this buffer.
getEncoding () {
return this.encoding || (this.file && this.file.getEncoding())
}
setPreferredLineEnding (preferredLineEnding = null) {
this.preferredLineEnding = preferredLineEnding
}
getPreferredLineEnding () {
return this.preferredLineEnding
}
// Public: Get the path of the associated file.
//
// Returns a {String}.
getUri () {
return this.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 () {
return this.file && this.file.getBaseName()
}
/*
Section: Reading Text
*/
// Public: Determine whether the buffer is empty.
//
// Returns a {Boolean}.
isEmpty () {
return this.buffer.getLength() === 0
}
// Public: Get the entire text of the buffer. Avoid using this unless you know that the
// buffer's text is reasonably short.
//
// Returns a {String}.
getText () {
if (this.cachedText == null) this.cachedText = this.buffer.getText()
return this.cachedText
}
getCharacterAtPosition (position) {
return this.buffer.getCharacterAtPosition(Point.fromObject(position))
}
// Public: Get the text in a range.
//
// * `range` A {Range}
//
// Returns a {String}
getTextInRange (range) {
return this.buffer.getTextInRange(Range.fromObject(range))
}
// Public: Get the text of all lines in the buffer, without their line endings.
//
// Returns an {Array} of {String}s.
getLines () {
return this.buffer.getLines()
}
// Public: Get the text of the last line of the buffer, without its line
// ending.
//
// Returns a {String}.
getLastLine () {
return this.lineForRow(this.getLastRow())
}
// Public: Get the text of the line at the given 0-indexed row, without its
// line ending.
//
// * `row` A {Number} representing the row.
//
// Returns a {String}.
lineForRow (row) {
return this.buffer.lineForRow(row)
}
// Public: Get the line ending for the given 0-indexed row.
//
// * `row` A {Number} indicating the row.
//
// Returns a {String}. The returned newline is represented as a literal string:
// `'\n'`, `'\r\n'`, or `''` for the last line of the buffer, which
// doesn't end in a newline.
lineEndingForRow (row) {
return this.buffer.lineEndingForRow(row)
}
// Public: Get the length of the line for the given 0-indexed row, without its
// line ending.
//
// * `row` A {Number} indicating the row.
//
// Returns a {Number}.
lineLengthForRow (row) {
return this.buffer.lineLengthForRow(row)
}
// Public: Determine if the given row contains only whitespace.
//
// * `row` A {Number} representing a 0-indexed row.
//
// Returns a {Boolean}.
isRowBlank (row) {
return !/\S/.test(this.lineForRow(row))
}
// Public: Given a row, find the first preceding row that's not blank.
//
// * `startRow` A {Number} identifying the row to start checking at.
//
// Returns a {Number} or `null` if there's no preceding non-blank row.
previousNonBlankRow (startRow) {
if (startRow === 0) return null
startRow = Math.min(startRow, this.getLastRow())
for (let row = startRow - 1; row >= 0; row--) {
if (!this.isRowBlank(row)) return row
}
return null
}
// Public: Given a row, find the next row that's not blank.
//
// * `startRow` A {Number} identifying the row to start checking at.
//
// Returns a {Number} or `null` if there's no next non-blank row.
nextNonBlankRow (startRow) {
const lastRow = this.getLastRow()
if (startRow < lastRow) {
for (let row = startRow + 1; row <= lastRow; row++) {
if (!this.isRowBlank(row)) return row
}
}
return null
}
// Extended: Return true if the buffer contains any astral-plane Unicode characters that
// are encoded as surrogate pairs.
//
// Returns a {Boolean}.
hasAstral () {
if (this.cachedHasAstral !== null) {
return this.cachedHasAstral
} else {
this.cachedHasAstral = this.buffer.hasAstral()
return this.cachedHasAstral
}
}
/*
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) {
return this.setTextInRange(this.getRange(), text, {normalizeLineEndings: 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) {
const currentText = this.getText()
if (currentText === text) return
const computeBufferColumn = function (str) {
const newlineIndex = str.lastIndexOf('\n')
if (newlineIndex === -1) {
return str.length
} else {
return str.length - newlineIndex - 1
}
}
this.transact(() => {
let row = 0
let column = 0
const currentPosition = [0, 0]
const lineDiff = diff.diffLines(currentText, text)
const changeOptions = {normalizeLineEndings: false}
for (let change of lineDiff) {
const lineCount = change.count
currentPosition[0] = row
currentPosition[1] = column
if (change.added) {
row += lineCount
column = computeBufferColumn(change.value)
this.setTextInRange([currentPosition, currentPosition], change.value, changeOptions)
} else if (change.removed) {
const endRow = row + lineCount
const endColumn = column + computeBufferColumn(change.value)
this.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}
// * `options` (optional) {Object}
// * `normalizeLineEndings` (optional) {Boolean} (default: true)
// * `undo` (optional) *Deprecated* {String} 'skip' will cause this change
// to be grouped with the preceding change for the purposes of undo and
// redo. This property is deprecated. Call groupLastChanges() on the
// buffer after instead.
//
// Returns the {Range} of the inserted text.
setTextInRange (range, newText, options) {
let normalizeLineEndings, undo
if (options) ({normalizeLineEndings, undo} = options)
if (normalizeLineEndings == null) normalizeLineEndings = true
if (undo != null) {
Grim.deprecate('The `undo` option is deprecated. Call groupLastChanges() on the TextBuffer afterward instead.')
}
if (this.transactCallDepth === 0) {
const newRange = this.transact(() => this.setTextInRange(range, newText, {normalizeLineEndings}))
if (undo === 'skip') this.groupLastChanges()
return newRange
}
const oldRange = this.clipRange(range)
const oldText = this.getTextInRange(oldRange)
if (normalizeLineEndings) {
const normalizedEnding = this.preferredLineEnding ||
this.lineEndingForRow(oldRange.start.row) ||
this.lineEndingForRow(oldRange.start.row - 1)
if (normalizedEnding) {
newText = newText.replace(newlineRegex, normalizedEnding)
}
}
const change = {
oldStart: oldRange.start,
oldEnd: oldRange.end,
newStart: oldRange.start,
newEnd: traverse(oldRange.start, extentForText(newText)),
oldText,
newText
}
const newRange = this.applyChange(change, true)
if (undo === 'skip') this.groupLastChanges()
return 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.
// * `options` (optional) {Object}
// * `normalizeLineEndings` (optional) {Boolean} (default: true)
// * `undo` (optional) *Deprecated* {String} 'skip' will skip the undo
// system. This property is deprecated. Call groupLastChanges() on the
// {TextBuffer} afterward instead.
//
// Returns the {Range} of the inserted text.
insert (position, text, options) {
return this.setTextInRange(new Range(position, position), text, options)
}
// Public: Append text to the end of the buffer.
//
// * `text` A {String} representing the text text to append.
// * `options` (optional) {Object}
// * `normalizeLineEndings` (optional) {Boolean} (default: true)
// * `undo` (optional) *Deprecated* {String} 'skip' will skip the undo
// system. This property is deprecated. Call groupLastChanges() on the
// {TextBuffer} afterward instead.
//
// Returns the {Range} of the inserted text
append (text, options) {
return this.insert(this.getEndPosition(), text, options)
}
// Applies a change to the buffer based on its old range and new text.
applyChange (change, pushToHistory = false) {
const {newStart, newEnd, oldStart, oldEnd, oldText, newText} = change
const oldExtent = traversal(oldEnd, oldStart)
const oldRange = Range(newStart, traverse(newStart, oldExtent))
oldRange.freeze()
const newExtent = traversal(newEnd, newStart)
const newRange = Range(newStart, traverse(newStart, newExtent))
newRange.freeze()
if (pushToHistory) {
if (!change.oldExtent) change.oldExtent = oldExtent
if (!change.newExtent) change.newExtent = newExtent
if (this.historyProvider) {
this.historyProvider.pushChange(change)
}
}
const changeEvent = {oldRange, newRange, oldText, newText}
for (const id in this.displayLayers) {
const displayLayer = this.displayLayers[id]
displayLayer.bufferWillChange(changeEvent)
}
this.emitWillChangeEvent()
this.buffer.setTextInRange(oldRange, newText)
if (this.markerLayers) {
for (const id in this.markerLayers) {
const markerLayer = this.markerLayers[id]
markerLayer.splice(oldRange.start, oldExtent, newExtent)
this.markerLayersWithPendingUpdateEvents.add(markerLayer)
}
}
this.cachedText = null
this.changesSinceLastDidChangeTextEvent.push(change)
this.changesSinceLastStoppedChangingEvent.push(change)
this.emitDidChangeEvent(changeEvent)
return newRange
}
emitDidChangeEvent (changeEvent) {
if (!changeEvent.oldRange.isEmpty() || !changeEvent.newRange.isEmpty()) {
this.languageMode.bufferDidChange(changeEvent)
for (const id in this.displayLayers) {
this.displayLayers[id].bufferDidChange(changeEvent)
}
}
}
// 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) {
return this.setTextInRange(range, '')
}
// Public: Delete the line associated with a specified 0-indexed row.
//
// * `row` A {Number} representing the row to delete.
//
// Returns the {Range} of the deleted text.
deleteRow (row) {
return this.deleteRows(row, row)
}
// Public: Delete the lines associated with the specified 0-indexed row range.
//
// If the row range is out of bounds, it will be clipped. If the `startRow` is
// greater than the `endRow`, 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) {
let endPoint, startPoint
const lastRow = this.getLastRow()
if (startRow > endRow) { [startRow, endRow] = [endRow, startRow] }
if (endRow < 0) {
return new Range(this.getFirstPosition(), this.getFirstPosition())
}
if (startRow > lastRow) {
return new Range(this.getEndPosition(), this.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 === 0) {
startPoint = new Point(startRow, 0)
} else {
startPoint = new Point(startRow - 1, this.lineLengthForRow(startRow - 1))
}
endPoint = new Point(endRow, this.lineLengthForRow(endRow))
}
return this.delete(new Range(startPoint, endPoint))
}
/*
Section: Markers
*/
// Public: Create a layer to contain a set of related markers.
//
// * `options` (optional) An {Object} contaning the following keys:
// * `maintainHistory` (optional) A {Boolean} indicating whether or not the
// state of this layer should be restored on undo/redo operations. Defaults
// to `false`.
// * `persistent` (optional) A {Boolean} indicating whether or not this
// marker layer should be serialized and deserialized along with the rest
// of the buffer. Defaults to `false`. If `true`, the marker layer's id
// will be maintained across the serialization boundary, allowing you to
// retrieve it via {::getMarkerLayer}.
// * `role` (optional) A {String} indicating role of this marker layer
//
// Returns a {MarkerLayer}.
addMarkerLayer (options) {
const layer = new MarkerLayer(this, String(this.nextMarkerLayerId++), options)
this.markerLayers[layer.id] = layer
return layer
}
// Public: Get a {MarkerLayer} by id.
//
// * `id` The id of the marker layer to retrieve.
//
// Returns a {MarkerLayer} or `undefined` if no layer exists with the given
// id.
getMarkerLayer (id) {
return this.markerLayers[id]
}
// Public: Get the default {MarkerLayer}.
//
// All {Marker} APIs not tied to an explicit layer interact with this default
// layer.
//
// Returns a {MarkerLayer}.
getDefaultMarkerLayer () {
return this.defaultMarkerLayer
}
// Public: Create a {Marker} with the given range in the default {MarkerLayer}.
// This marker will maintain its logical location as the buffer is changed,
// so if you mark a particular word, the marker will remain over that word
// even if the word's location in the buffer changes.
//
// * `range` A {Range} or range-compatible {Array}
// * `properties` (optional) A hash of key-value pairs to associate with the
// marker. There are also reserved property names that have marker-specific
// meaning.
// * `reversed` (optional) {Boolean} Creates the marker in a reversed
// orientation. (default: false)
// * `invalidate` (optional) {String} Determines the rules by which changes
// to the buffer *invalidate* the marker. (default: 'overlap') It can be
// any of the following strategies, in order of fragility:
// * __never__: The marker is never marked as invalid. This is a good choice for
// markers representing selections in an editor.
// * __surround__: The marker is invalidated by changes that completely surround it.
// * __overlap__: The marker is invalidated by changes that surround the
// start or end of the marker. This is the default.
// * __inside__: The marker is invalidated by changes that extend into the
// inside of the marker. Changes that end at the marker's start or
// start at the marker's end do not invalidate the marker.
// * __touch__: The marker is invalidated by a change that touches the marked
// region in any way, including changes that end at the marker's
// start or start at the marker's end. This is the most fragile strategy.
// * `exclusive` (optional) {Boolean} indicating whether insertions at the
// start or end of the marked range should be interpreted as happening
// *outside* the marker. Defaults to `false`, except when using the
// `inside` invalidation strategy or when when the marker has no tail, in
// which case it defaults to true. Explicitly assigning this option
// overrides behavior in all circumstances.
//
// Returns a {Marker}.
markRange (range, properties) {
return this.defaultMarkerLayer.markRange(range, properties)
}
// Public: Create a {Marker} at the given position with no tail in the default
// marker layer.
//
// * `position` {Point} or point-compatible {Array}
// * `options` (optional) An {Object} with the following keys:
// * `invalidate` (optional) {String} Determines the rules by which changes
// to the buffer *invalidate* the marker. (default: 'overlap') It can be
// any of the following strategies, in order of fragility:
// * __never__: The marker is never marked as invalid. This is a good choice for
// markers representing selections in an editor.
// * __surround__: The marker is invalidated by changes that completely surround it.
// * __overlap__: The marker is invalidated by changes that surround the
// start or end of the marker. This is the default.
// * __inside__: The marker is invalidated by changes that extend into the
// inside of the marker. Changes that end at the marker's start or
// start at the marker's end do not invalidate the marker.
// * __touch__: The marker is invalidated by a change that touches the marked
// region in any way, including changes that end at the marker's
// start or start at the marker's end. This is the most fragile strategy.
// * `exclusive` (optional) {Boolean} indicating whether insertions at the
// start or end of the marked range should be interpreted as happening
// *outside* the marker. Defaults to `false`, except when using the
// `inside` invalidation strategy or when when the marker has no tail, in
// which case it defaults to true. Explicitly assigning this option
// overrides behavior in all circumstances.
//
// Returns a {Marker}.
markPosition (position, options) {
return this.defaultMarkerLayer.markPosition(position, options)
}
// Public: Get all existing markers on the default marker layer.
//
// Returns an {Array} of {Marker}s.
getMarkers () {
return this.defaultMarkerLayer.getMarkers()
}
// Public: Get an existing marker by its id from the default marker layer.
//
// * `id` {Number} id of the marker to retrieve
//
// Returns a {Marker}.
getMarker (id) {
return this.defaultMarkerLayer.getMarker(id)
}
// Public: Find markers conforming to the given parameters in the default
// marker layer.
//
// Markers are sorted based on their position in the buffer. If two markers
// start at the same position, the larger marker comes first.
//
// * `params` A hash of key-value pairs constraining the set of returned markers. You
// can query against custom marker properties by listing the desired
// key-value pairs here. In addition, the following keys are reserved and
// have special semantics:
// * `startPosition` Only include markers that start at the given {Point}.
// * `endPosition` Only include markers that end at the given {Point}.
// * `startsInRange` Only include markers that start inside the given {Range}.
// * `endsInRange` Only include markers that end inside the given {Range}.
// * `containsPoint` Only include markers that contain the given {Point}, inclusive.
// * `containsRange` Only include markers that contain the given {Range}, inclusive.
// * `startRow` Only include markers that start at the given row {Number}.
// * `endRow` Only include markers that end at the given row {Number}.
// * `intersectsRow` Only include markers that intersect the given row {Number}.
//
// Returns an {Array} of {Marker}s.
findMarkers (params) {
return this.defaultMarkerLayer.findMarkers(params)
}
// Public: Get the number of markers in the default marker layer.
//
// Returns a {Number}.
getMarkerCount () {
return this.defaultMarkerLayer.getMarkerCount()
}
destroyMarker (id) {
const marker = this.getMarker(id)
if (marker) marker.destroy()
}
/*
Section: History
*/
setHistoryProvider (historyProvider) {
this.historyProvider = historyProvider
}
restoreDefaultHistoryProvider (history) {
const provider = new DefaultHistoryProvider(this)
provider.restoreFromSnapshot(history)
return this.setHistoryProvider(provider)
}
getHistory (maxEntries) {
if (this.transactCallDepth > 0) {
throw new Error('Cannot build history snapshots within transactions')
}
const snapshot = this.historyProvider.getSnapshot(maxEntries)
const baseTextBuffer = new NativeTextBuffer(this.getText())
for (let i = snapshot.undoStackChanges.length - 1; i >= 0; i--) {
const change = snapshot.undoStackChanges[i]
const newRange = Range(change.newStart, change.newEnd)
baseTextBuffer.setTextInRange(newRange, change.oldText)
}
return {
baseText: baseTextBuffer.getText(),
undoStack: snapshot.undoStack,
redoStack: snapshot.redoStack,
nextCheckpointId: snapshot.nextCheckpointId
}
}
// Provide fallback in case people are using this renamed private field in packages.
get history () {
return this.historyProvider
}
// Public: Undo the last operation. If a transaction is in progress, aborts it.
//
// * `options` (optional) {Object}
// * `selectionsMarkerLayer` (optional)
// Restore snapshot of selections marker layer to given selectionsMarkerLayer.
//
// Returns a {Boolean} of whether or not a change was made.
undo (options) {
const pop = this.historyProvider.undo()
if (!pop) return false
this.emitWillChangeEvent()
this.transactCallDepth++
try {
for (const change of pop.textUpdates) {
this.applyChange(change)
}
} finally {
this.transactCallDepth--
}
this.restoreFromMarkerSnapshot(pop.markers, options && options.selectionsMarkerLayer)
this.emitDidChangeTextEvent()
this.emitMarkerChangeEvents(pop.markers)
return true
}
// Public: Redo the last operation
//
// * `options` (optional) {Object}
// * `selectionsMarkerLayer` (optional)
// Restore snapshot of selections marker layer to given selectionsMarkerLayer.
//
// Returns a {Boolean} of whether or not a change was made.
redo (options) {
const pop = this.historyProvider.redo()
if (!pop) return false
this.emitWillChangeEvent()
this.transactCallDepth++
try {
for (const change of pop.textUpdates) {
this.applyChange(change)
}
} finally {
this.transactCallDepth--
}
this.restoreFromMarkerSnapshot(pop.markers, options && options.selectionsMarkerLayer)
this.emitDidChangeTextEvent()
this.emitMarkerChangeEvents(pop.markers)
return true
}
// 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.
//
// * `options` (optional) {Object}
// * `groupingInterval` (optional) The {Number} of milliseconds for which this
// transaction should be considered 'open for grouping' after it begins. If
// a transaction with a positive `groupingInterval` is committed while the
// previous transaction is still open for grouping, the two transactions
// are merged with respect to undo and redo.
// * `selectionsMarkerLayer` (optional)
// When provided, skip taking snapshot for other selections markerLayers except given one.
// * `groupingInterval` (optional) The {Number} of milliseconds for which this
// transaction should be considered 'open for grouping' after it begins. If a
// transaction with a positive `groupingInterval` is committed while the previous
// transaction is still open for grouping, the two transactions are merged with
// respect to undo and redo.
// * `fn` A {Function} to call inside the transaction.
transact (options, fn) {
let groupingInterval, result, selectionsMarkerLayer
if (typeof options === 'function') {
fn = options
groupingInterval = 0
} else if (typeof options === 'object') {
({groupingInterval, selectionsMarkerLayer} = options)
if (groupingInterval == null) { groupingInterval = 0 }
} else {
groupingInterval = options
}
const checkpointBefore = this.historyProvider.createCheckpoint({
markers: this.createMarkerSnapshot(selectionsMarkerLayer),
isBarrier: true
})
try {
this.transactCallDepth++
result = fn()
} catch (exception) {
this.revertToCheckpoint(checkpointBefore, {deleteCheckpoint: true})
if (!(exception instanceof TransactionAbortedError)) throw exception
return
} finally {
this.transactCallDepth--
}
if (this.isDestroyed()) return result
const endMarkerSnapshot = this.createMarkerSnapshot(selectionsMarkerLayer)
this.historyProvider.groupChangesSinceCheckpoint(checkpointBefore, {
markers: endMarkerSnapshot,
deleteCheckpoint: true
})
this.historyProvider.applyGroupingInterval(groupingInterval)
this.historyProvider.enforceUndoStackSizeLimit()
this.emitDidChangeTextEvent()
this.emitMarkerChangeEvents(endMarkerSnapshot)
return result
}
// Public: Abort the currently running transaction
//
// Only intended to be called within the `fn` option to {::transact}
abortTransaction () {
throw new TransactionAbortedError('Transaction aborted.')
}
// Public: Clear the undo stack.
clearUndoStack () {
return this.historyProvider.clearUndoStack()
}
// Public: Create a pointer to the current state of the buffer for use
// with {::revertToCheckpoint} and {::groupChangesSinceCheckpoint}.
//
// * `options` (optional) {Object}
// * `selectionsMarkerLayer` (optional)
// When provided, skip taking snapshot for other selections markerLayers except given one.
//
// Returns a checkpoint id value.
createCheckpoint (options) {
return this.historyProvider.createCheckpoint({markers: this.createMarkerSnapshot(options != null ? options.selectionsMarkerLayer : undefined), isBarrier: false})
}
// Public: Revert the buffer to the state it was in when the given
// checkpoint was created.
//
// The redo stack will be empty following this operation, so changes since the
// checkpoint will be lost. If the given checkpoint is no longer present in the
// undo history, no changes will be made to the buffer and this method will
// return `false`.
//
// * `checkpoint` {Number} id of the checkpoint to revert to.
// * `options` (optional) {Object}
// * `selectionsMarkerLayer` (optional)
// Restore snapshot of selections marker layer to given selectionsMarkerLayer.
//
// Returns a {Boolean} indicat