UNPKG

text-buffer

Version:

A container for large mutable strings with annotated regions

1,379 lines (1,242 loc) 92.1 kB
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