diff --git a/.coffeelintignore b/.coffeelintignore deleted file mode 100644 index 1db51fed75..0000000000 --- a/.coffeelintignore +++ /dev/null @@ -1 +0,0 @@ -spec/fixtures diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 387c2b67bd..7c04f2e047 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,10 @@ name: CI -on: [push] +on: + pull_request: + push: + branches: + - master env: CI: true @@ -9,18 +13,32 @@ jobs: Test: strategy: matrix: - os: [ubuntu-latest, macos-latest, windows-latest] + os: [ubuntu-latest, macos-latest] + node_arch: + - x64 + fail-fast: false runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: '16' - - name: Install Python setuptools + node-version: '20.11.1' + architecture: ${{ matrix.node_arch }} + - name: Install Python setuptools (Unix-likes) # This is needed for Python 3.12+, since many versions of node-gyp # are incompatible with Python 3.12+, which no-longer ships 'distutils' # out of the box. 'setuptools' package provides 'distutils'. - run: python3 -m pip install setuptools + if: ${{ runner.os != 'Windows' }} + run: python3 -m pip install --break-system-packages setuptools + - name: Install Python setuptools (Windows) + # This is needed for Python 3.12+, since many versions of node-gyp + # are incompatible with Python 3.12+, which no-longer ships 'distutils' + # out of the box. 'setuptools' package provides 'distutils'. + if: ${{ runner.os == 'Windows' }} + run: | + python3 -m venv CI_venv + CI_venv\Scripts\activate.bat + python3 -m pip install setuptools - name: Install windows-build-tools if: ${{ matrix.os == 'windows-latest' }} run: npm config set msvs_version 2019 diff --git a/.gitignore b/.gitignore index bea30eb700..d994319218 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,8 @@ +.DS_Store +.tool-versions node_modules *.swp lib npm-debug.log -.coffee api.json package-lock.json diff --git a/.npmignore b/.npmignore index 210aac2665..d4c42e7fe9 100644 --- a/.npmignore +++ b/.npmignore @@ -1,10 +1,8 @@ spec script src -*.coffee .npmignore .DS_Store npm-debug.log .travis.yml .pairs -.coffee diff --git a/README.md b/README.md index 32c381198b..c14ed01fef 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,3 @@ -# Atom TextBuffer Core -[![CI](https://github.com/atom/text-buffer/actions/workflows/ci.yml/badge.svg)](https://github.com/atom/text-buffer/actions/workflows/ci.yml) +# Pulsar TextBuffer Core -This is the core of the Atom text buffer, separated into its own module so its tests can be run headless. It handles the storage and manipulation of text and associated regions (markers). +This is the core of the Pulsar's text buffer, separated into its own module so its tests can be run headless. It handles the storage and manipulation of text and associated regions (markers). diff --git a/coffeelint.json b/coffeelint.json deleted file mode 100644 index a5dd715e38..0000000000 --- a/coffeelint.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "max_line_length": { - "level": "ignore" - }, - "no_empty_param_list": { - "level": "error" - }, - "arrow_spacing": { - "level": "error" - }, - "no_interpolation_in_single_quotes": { - "level": "error" - }, - "no_debugger": { - "level": "error" - }, - "prefer_english_operator": { - "level": "error" - }, - "colon_assignment_spacing": { - "spacing": { - "left": 0, - "right": 1 - }, - "level": "error" - }, - "braces_spacing": { - "spaces": 0, - "level": "error" - }, - "spacing_after_comma": { - "level": "error" - }, - "no_stand_alone_at": { - "level": "error" - } -} diff --git a/package.json b/package.json index fddec0944a..6e5fa6e543 100644 --- a/package.json +++ b/package.json @@ -1,49 +1,42 @@ { - "name": "text-buffer", + "name": "@pulsar-edit/text-buffer", "version": "13.18.6", "description": "A container for large mutable strings with annotated regions", - "main": "./lib/text-buffer", + "main": "./src/text-buffer", "scripts": { - "prepublish": "npm run clean && npm run compile && npm run lint && npm run docs", - "docs": "node script/generate-docs", + "prepublish": "npm run clean", "clean": "rimraf lib api.json", - "compile": "coffee --no-header --output lib --compile src && cpy src/*.js lib/", - "lint": "coffeelint -r src spec && standard src/*.js spec/*.js", "test": "node script/test", - "ci": "npm run compile && npm run lint && npm run test && npm run bench", + "ci": "npm run test && npm run bench", "bench": "node benchmarks/index" }, "repository": { "type": "git", - "url": "https://github.com/atom/text-buffer.git" + "url": "https://github.com/pulsar/text-buffer.git" }, "bugs": { - "url": "https://github.com/atom/text-buffer/issues" + "url": "https://github.com/pulsar/text-buffer/issues" }, "atomTestRunner": "atom-jasmine2-test-runner", "license": "MIT", "devDependencies": { "atom-jasmine2-test-runner": "^1.0.0", - "coffee-cache": "^0.2.0", - "coffee-script": "^1.10.0", - "coffeelint": "1.16.0", "cpy-cli": "^1.0.1", "dedent": "^0.6.0", - "donna": "^1.0.16", - "electron": "^6", + "electron": "^30", "jasmine": "^2.4.1", "jasmine-core": "^2.4.1", - "joanna": "0.0.11", "json-diff": "^0.3.1", "random-seed": "^0.2.0", "regression": "^1.2.1", "rimraf": "~2.2.2", "standard": "^10.0.3", - "tello": "^1.0.7", "temp": "^0.8.3", "yargs": "^6.5.0" }, "dependencies": { + "@pulsar-edit/pathwatcher": "^9.0.2", + "@pulsar-edit/superstring": "^3.0.4", "delegato": "^1.0.0", "diff": "^2.2.1", "emissary": "^1.0.0", @@ -52,9 +45,7 @@ "fs-plus": "^3.0.0", "grim": "^2.0.2", "mkdirp": "^0.5.1", - "pathwatcher": "^8.1.0", "serializable": "^1.0.3", - "superstring": "^2.4.4", "underscore-plus": "^1.0.0", "winattr": "^3.0.0" }, diff --git a/script/generate-docs b/script/generate-docs deleted file mode 100755 index 67a4d295f6..0000000000 --- a/script/generate-docs +++ /dev/null @@ -1,25 +0,0 @@ -#!/usr/bin/env node - -require('coffee-script').register() - -const fs = require('fs') -const path = require('path') -const tello = require('tello') -const donna = require('donna') -const joanna = require('joanna') - -const rootDir = path.join(__dirname, '..') - -const sourceDir = 'src' -const jsFiles = [] -for (const entry of fs.readdirSync('src')) { - if (entry.endsWith('.js')) { - jsFiles.push(path.join(sourceDir, entry)) - } -} - -const jsMetadata = joanna(jsFiles) -const coffeeMetadata = donna.generateMetadata([rootDir]) -Object.assign(coffeeMetadata[0].files, jsMetadata.files) -const docs = tello.digest(coffeeMetadata) -fs.writeFileSync(path.join(rootDir, 'api.json'), JSON.stringify(docs, null, 2)) diff --git a/spec/display-marker-layer-spec.coffee b/spec/display-marker-layer-spec.coffee deleted file mode 100644 index 2761975c07..0000000000 --- a/spec/display-marker-layer-spec.coffee +++ /dev/null @@ -1,413 +0,0 @@ -TextBuffer = require '../src/text-buffer' -Point = require '../src/point' -Range = require '../src/range' -SampleText = require './helpers/sample-text' - -describe "DisplayMarkerLayer", -> - beforeEach -> - jasmine.addCustomEqualityTester(require("underscore-plus").isEqual) - - it "allows DisplayMarkers to be created and manipulated in screen coordinates", -> - buffer = new TextBuffer(text: 'abc\ndef\nghi\nj\tk\tl\nmno') - displayLayer = buffer.addDisplayLayer(tabLength: 4) - markerLayer = displayLayer.addMarkerLayer() - - marker = markerLayer.markScreenRange([[3, 4], [4, 2]]) - expect(marker.getScreenRange()).toEqual [[3, 4], [4, 2]] - expect(marker.getBufferRange()).toEqual [[3, 2], [4, 2]] - - markerChangeEvents = [] - marker.onDidChange (change) -> markerChangeEvents.push(change) - - marker.setScreenRange([[3, 8], [4, 3]]) - - expect(marker.getBufferRange()).toEqual([[3, 4], [4, 3]]) - expect(marker.getScreenRange()).toEqual([[3, 8], [4, 3]]) - expect(markerChangeEvents[0]).toEqual { - oldHeadBufferPosition: [4, 2] - newHeadBufferPosition: [4, 3] - oldTailBufferPosition: [3, 2] - newTailBufferPosition: [3, 4] - oldHeadScreenPosition: [4, 2] - newHeadScreenPosition: [4, 3] - oldTailScreenPosition: [3, 4] - newTailScreenPosition: [3, 8] - wasValid: true - isValid: true - textChanged: false - } - - markerChangeEvents = [] - buffer.insert([4, 0], '\t') - - expect(marker.getBufferRange()).toEqual([[3, 4], [4, 4]]) - expect(marker.getScreenRange()).toEqual([[3, 8], [4, 7]]) - expect(markerChangeEvents[0]).toEqual { - oldHeadBufferPosition: [4, 3] - newHeadBufferPosition: [4, 4] - oldTailBufferPosition: [3, 4] - newTailBufferPosition: [3, 4] - oldHeadScreenPosition: [4, 3] - newHeadScreenPosition: [4, 7] - oldTailScreenPosition: [3, 8] - newTailScreenPosition: [3, 8] - wasValid: true - isValid: true - textChanged: true - } - - expect(markerLayer.getMarker(marker.id)).toBe marker - - markerChangeEvents = [] - foldId = displayLayer.foldBufferRange([[0, 2], [2, 2]]) - - expect(marker.getBufferRange()).toEqual([[3, 4], [4, 4]]) - expect(marker.getScreenRange()).toEqual([[1, 8], [2, 7]]) - expect(markerChangeEvents[0]).toEqual { - oldHeadBufferPosition: [4, 4] - newHeadBufferPosition: [4, 4] - oldTailBufferPosition: [3, 4] - newTailBufferPosition: [3, 4] - oldHeadScreenPosition: [4, 7] - newHeadScreenPosition: [2, 7] - oldTailScreenPosition: [3, 8] - newTailScreenPosition: [1, 8] - wasValid: true - isValid: true - textChanged: false - } - - markerChangeEvents = [] - displayLayer.destroyFold(foldId) - - expect(marker.getBufferRange()).toEqual([[3, 4], [4, 4]]) - expect(marker.getScreenRange()).toEqual([[3, 8], [4, 7]]) - expect(markerChangeEvents[0]).toEqual { - oldHeadBufferPosition: [4, 4] - newHeadBufferPosition: [4, 4] - oldTailBufferPosition: [3, 4] - newTailBufferPosition: [3, 4] - oldHeadScreenPosition: [2, 7] - newHeadScreenPosition: [4, 7] - oldTailScreenPosition: [1, 8] - newTailScreenPosition: [3, 8] - wasValid: true - isValid: true - textChanged: false - } - - markerChangeEvents = [] - displayLayer.reset({tabLength: 3}) - - expect(marker.getBufferRange()).toEqual([[3, 4], [4, 4]]) - expect(marker.getScreenRange()).toEqual([[3, 6], [4, 6]]) - expect(markerChangeEvents[0]).toEqual { - oldHeadBufferPosition: [4, 4] - newHeadBufferPosition: [4, 4] - oldTailBufferPosition: [3, 4] - newTailBufferPosition: [3, 4] - oldHeadScreenPosition: [4, 7] - newHeadScreenPosition: [4, 6] - oldTailScreenPosition: [3, 8] - newTailScreenPosition: [3, 6] - wasValid: true - isValid: true - textChanged: false - } - - it "does not create duplicate DisplayMarkers when it has onDidCreateMarker observers (regression)", -> - buffer = new TextBuffer(text: 'abc\ndef\nghi\nj\tk\tl\nmno') - displayLayer = buffer.addDisplayLayer(tabLength: 4) - markerLayer = displayLayer.addMarkerLayer() - - emittedMarker = null - markerLayer.onDidCreateMarker (marker) -> - emittedMarker = marker - - createdMarker = markerLayer.markBufferRange([[0, 1], [2, 3]]) - expect(createdMarker).toBe(emittedMarker) - - it "emits events when markers are created and destroyed", -> - buffer = new TextBuffer(text: 'hello world') - displayLayer = buffer.addDisplayLayer(tabLength: 4) - markerLayer = displayLayer.addMarkerLayer() - createdMarkers = [] - markerLayer.onDidCreateMarker (m) -> createdMarkers.push(m) - marker = markerLayer.markScreenRange([[0, 4], [1, 4]]) - - expect(createdMarkers).toEqual [marker] - - destroyEventCount = 0 - marker.onDidDestroy -> destroyEventCount++ - - marker.destroy() - expect(destroyEventCount).toBe 1 - - it "emits update events when markers are created, updated directly, updated indirectly, or destroyed", (done) -> - buffer = new TextBuffer(text: 'hello world') - displayLayer = buffer.addDisplayLayer(tabLength: 4) - markerLayer = displayLayer.addMarkerLayer() - marker = null - - updateEventCount = 0 - markerLayer.onDidUpdate -> - updateEventCount++ - if updateEventCount is 1 - marker.setScreenRange([[0, 5], [1, 0]]) - else if updateEventCount is 2 - buffer.insert([0, 0], '\t') - else if updateEventCount is 3 - marker.destroy() - else if updateEventCount is 4 - done() - - buffer.transact -> - marker = markerLayer.markScreenRange([[0, 4], [1, 4]]) - - it "allows markers to be copied", -> - buffer = new TextBuffer(text: '\ta\tbc\tdef\tg\n\th') - displayLayer = buffer.addDisplayLayer(tabLength: 4) - markerLayer = displayLayer.addMarkerLayer() - - markerA = markerLayer.markScreenRange([[0, 4], [1, 4]], a: 1, b: 2) - markerB = markerA.copy(b: 3, c: 4) - - expect(markerB.id).not.toBe(markerA.id) - expect(markerB.getProperties()).toEqual({a: 1, b: 3, c: 4}) - expect(markerB.getScreenRange()).toEqual(markerA.getScreenRange()) - - describe "::destroy()", -> - it "only destroys the underlying buffer MarkerLayer if the DisplayMarkerLayer was created by calling addMarkerLayer on its parent DisplayLayer", -> - buffer = new TextBuffer(text: 'abc\ndef\nghi\nj\tk\tl\nmno') - displayLayer1 = buffer.addDisplayLayer(tabLength: 2) - displayLayer2 = buffer.addDisplayLayer(tabLength: 4) - bufferMarkerLayer = buffer.addMarkerLayer() - bufferMarker1 = bufferMarkerLayer.markRange [[2, 1], [2, 2]] - displayMarkerLayer1 = displayLayer1.getMarkerLayer(bufferMarkerLayer.id) - displayMarker1 = displayMarkerLayer1.markBufferRange [[1, 0], [1, 2]] - displayMarkerLayer2 = displayLayer2.getMarkerLayer(bufferMarkerLayer.id) - displayMarker2 = displayMarkerLayer2.markBufferRange [[2, 0], [2, 1]] - displayMarkerLayer3 = displayLayer2.addMarkerLayer() - displayMarker3 = displayMarkerLayer3.markBufferRange [[0, 0], [0, 0]] - - displayMarkerLayer1DestroyEventCount = 0 - displayMarkerLayer1.onDidDestroy -> displayMarkerLayer1DestroyEventCount++ - displayMarkerLayer2DestroyEventCount = 0 - displayMarkerLayer2.onDidDestroy -> displayMarkerLayer2DestroyEventCount++ - displayMarkerLayer3DestroyEventCount = 0 - displayMarkerLayer3.onDidDestroy -> displayMarkerLayer3DestroyEventCount++ - - displayMarkerLayer1.destroy() - expect(bufferMarkerLayer.isDestroyed()).toBe(false) - expect(displayMarkerLayer1.isDestroyed()).toBe(true) - expect(displayMarkerLayer1DestroyEventCount).toBe(1) - expect(bufferMarker1.isDestroyed()).toBe(false) - expect(displayMarker1.isDestroyed()).toBe(true) - expect(displayMarker2.isDestroyed()).toBe(false) - expect(displayMarker3.isDestroyed()).toBe(false) - - displayMarkerLayer2.destroy() - expect(bufferMarkerLayer.isDestroyed()).toBe(false) - expect(displayMarkerLayer2.isDestroyed()).toBe(true) - expect(displayMarkerLayer2DestroyEventCount).toBe(1) - expect(bufferMarker1.isDestroyed()).toBe(false) - expect(displayMarker1.isDestroyed()).toBe(true) - expect(displayMarker2.isDestroyed()).toBe(true) - expect(displayMarker3.isDestroyed()).toBe(false) - - bufferMarkerLayer.destroy() - expect(bufferMarkerLayer.isDestroyed()).toBe(true) - expect(displayMarkerLayer1DestroyEventCount).toBe(1) - expect(displayMarkerLayer2DestroyEventCount).toBe(1) - expect(bufferMarker1.isDestroyed()).toBe(true) - expect(displayMarker1.isDestroyed()).toBe(true) - expect(displayMarker2.isDestroyed()).toBe(true) - expect(displayMarker3.isDestroyed()).toBe(false) - - displayMarkerLayer3.destroy() - expect(displayMarkerLayer3.bufferMarkerLayer.isDestroyed()).toBe(true) - expect(displayMarkerLayer3.isDestroyed()).toBe(true) - expect(displayMarkerLayer3DestroyEventCount).toBe(1) - expect(displayMarker3.isDestroyed()).toBe(true) - - it "destroys the layer's markers", -> - buffer = new TextBuffer() - displayLayer = buffer.addDisplayLayer() - displayMarkerLayer = displayLayer.addMarkerLayer() - - marker1 = displayMarkerLayer.markBufferRange([[0, 0], [0, 0]]) - marker2 = displayMarkerLayer.markBufferRange([[0, 0], [0, 0]]) - - destroyListener = jasmine.createSpy('onDidDestroy listener') - marker1.onDidDestroy(destroyListener) - - displayMarkerLayer.destroy() - - expect(destroyListener).toHaveBeenCalled() - expect(marker1.isDestroyed()).toBe(true) - - # Markers states are updated regardless of whether they have an - # ::onDidDestroy listener - expect(marker2.isDestroyed()).toBe(true) - - it "destroys display markers when their underlying buffer markers are destroyed", -> - buffer = new TextBuffer(text: '\tabc') - displayLayer1 = buffer.addDisplayLayer(tabLength: 2) - displayLayer2 = buffer.addDisplayLayer(tabLength: 4) - bufferMarkerLayer = buffer.addMarkerLayer() - displayMarkerLayer1 = displayLayer1.getMarkerLayer(bufferMarkerLayer.id) - displayMarkerLayer2 = displayLayer2.getMarkerLayer(bufferMarkerLayer.id) - - bufferMarker = bufferMarkerLayer.markRange([[0, 1], [0, 2]]) - - displayMarker1 = displayMarkerLayer1.getMarker(bufferMarker.id) - displayMarker2 = displayMarkerLayer2.getMarker(bufferMarker.id) - expect(displayMarker1.getScreenRange()).toEqual([[0, 2], [0, 3]]) - expect(displayMarker2.getScreenRange()).toEqual([[0, 4], [0, 5]]) - - displayMarker1DestroyCount = 0 - displayMarker2DestroyCount = 0 - displayMarker1.onDidDestroy -> displayMarker1DestroyCount++ - displayMarker2.onDidDestroy -> displayMarker2DestroyCount++ - - bufferMarker.destroy() - expect(displayMarker1DestroyCount).toBe(1) - expect(displayMarker2DestroyCount).toBe(1) - - it "does not throw exceptions when buffer markers are destroyed that don't have corresponding display markers", -> - buffer = new TextBuffer(text: '\tabc') - displayLayer1 = buffer.addDisplayLayer(tabLength: 2) - displayLayer2 = buffer.addDisplayLayer(tabLength: 4) - bufferMarkerLayer = buffer.addMarkerLayer() - displayMarkerLayer1 = displayLayer1.getMarkerLayer(bufferMarkerLayer.id) - displayMarkerLayer2 = displayLayer2.getMarkerLayer(bufferMarkerLayer.id) - - bufferMarker = bufferMarkerLayer.markRange([[0, 1], [0, 2]]) - bufferMarker.destroy() - - it "destroys itself when the underlying buffer marker layer is destroyed", -> - buffer = new TextBuffer(text: 'abc\ndef\nghi\nj\tk\tl\nmno') - displayLayer1 = buffer.addDisplayLayer(tabLength: 2) - displayLayer2 = buffer.addDisplayLayer(tabLength: 4) - - bufferMarkerLayer = buffer.addMarkerLayer() - displayMarkerLayer1 = displayLayer1.getMarkerLayer(bufferMarkerLayer.id) - displayMarkerLayer2 = displayLayer2.getMarkerLayer(bufferMarkerLayer.id) - displayMarkerLayer1DestroyEventCount = 0 - displayMarkerLayer1.onDidDestroy -> displayMarkerLayer1DestroyEventCount++ - displayMarkerLayer2DestroyEventCount = 0 - displayMarkerLayer2.onDidDestroy -> displayMarkerLayer2DestroyEventCount++ - - bufferMarkerLayer.destroy() - expect(displayMarkerLayer1.isDestroyed()).toBe(true) - expect(displayMarkerLayer1DestroyEventCount).toBe(1) - expect(displayMarkerLayer2.isDestroyed()).toBe(true) - expect(displayMarkerLayer2DestroyEventCount).toBe(1) - - describe "findMarkers(params)", -> - [markerLayer, displayLayer] = [] - - beforeEach -> - buffer = new TextBuffer(text: SampleText) - displayLayer = buffer.addDisplayLayer(tabLength: 4) - markerLayer = displayLayer.addMarkerLayer() - - it "allows the startBufferRow and endBufferRow to be specified", -> - marker1 = markerLayer.markBufferRange([[0, 0], [3, 0]], class: 'a') - marker2 = markerLayer.markBufferRange([[0, 0], [5, 0]], class: 'a') - marker3 = markerLayer.markBufferRange([[9, 0], [10, 0]], class: 'b') - - expect(markerLayer.findMarkers(class: 'a', startBufferRow: 0)).toEqual [marker2, marker1] - expect(markerLayer.findMarkers(class: 'a', startBufferRow: 0, endBufferRow: 3)).toEqual [marker1] - expect(markerLayer.findMarkers(endBufferRow: 10)).toEqual [marker3] - - it "allows the startScreenRow and endScreenRow to be specified", -> - marker1 = markerLayer.markBufferRange([[6, 0], [7, 0]], class: 'a') - marker2 = markerLayer.markBufferRange([[9, 0], [10, 0]], class: 'a') - displayLayer.foldBufferRange([[4, 0], [7, 0]]) - expect(markerLayer.findMarkers(class: 'a', startScreenRow: 6, endScreenRow: 7)).toEqual [marker2] - - displayLayer.destroyFoldsIntersectingBufferRange([[4, 0], [7, 0]]) - displayLayer.foldBufferRange([[0, 20], [12, 2]]) - marker3 = markerLayer.markBufferRange([[12, 0], [12, 0]], class: 'a') - expect(markerLayer.findMarkers(class: 'a', startScreenRow: 0)).toEqual [marker1, marker2, marker3] - expect(markerLayer.findMarkers(class: 'a', endScreenRow: 0)).toEqual [marker1, marker2, marker3] - - it "allows the startsInBufferRange/endsInBufferRange and startsInScreenRange/endsInScreenRange to be specified", -> - marker1 = markerLayer.markBufferRange([[5, 2], [5, 4]], class: 'a') - marker2 = markerLayer.markBufferRange([[8, 0], [8, 2]], class: 'a') - displayLayer.foldBufferRange([[4, 0], [7, 0]]) - expect(markerLayer.findMarkers(class: 'a', startsInBufferRange: [[5, 1], [5, 3]])).toEqual [marker1] - expect(markerLayer.findMarkers(class: 'a', endsInBufferRange: [[8, 1], [8, 3]])).toEqual [marker2] - expect(markerLayer.findMarkers(class: 'a', startsInScreenRange: [[4, 0], [4, 1]])).toEqual [marker1] - expect(markerLayer.findMarkers(class: 'a', endsInScreenRange: [[5, 1], [5, 3]])).toEqual [marker2] - - it "allows intersectsBufferRowRange to be specified", -> - marker1 = markerLayer.markBufferRange([[5, 0], [5, 0]], class: 'a') - marker2 = markerLayer.markBufferRange([[8, 0], [8, 0]], class: 'a') - displayLayer.foldBufferRange([[4, 0], [7, 0]]) - expect(markerLayer.findMarkers(class: 'a', intersectsBufferRowRange: [5, 6])).toEqual [marker1] - - it "allows intersectsScreenRowRange to be specified", -> - marker1 = markerLayer.markBufferRange([[5, 0], [5, 0]], class: 'a') - marker2 = markerLayer.markBufferRange([[8, 0], [8, 0]], class: 'a') - displayLayer.foldBufferRange([[4, 0], [7, 0]]) - expect(markerLayer.findMarkers(class: 'a', intersectsScreenRowRange: [5, 10])).toEqual [marker2] - - displayLayer.destroyAllFolds() - displayLayer.foldBufferRange([[0, 20], [12, 2]]) - expect(markerLayer.findMarkers(class: 'a', intersectsScreenRowRange: [0, 0])).toEqual [marker1, marker2] - - displayLayer.destroyAllFolds() - displayLayer.reset({softWrapColumn: 10}) - marker1.setHeadScreenPosition([6, 5]) - marker2.setHeadScreenPosition([9, 2]) - expect(markerLayer.findMarkers(class: 'a', intersectsScreenRowRange: [5, 7])).toEqual [marker1] - - it "allows containedInScreenRange to be specified", -> - marker1 = markerLayer.markBufferRange([[5, 0], [5, 0]], class: 'a') - marker2 = markerLayer.markBufferRange([[8, 0], [8, 0]], class: 'a') - displayLayer.foldBufferRange([[4, 0], [7, 0]]) - expect(markerLayer.findMarkers(class: 'a', containedInScreenRange: [[5, 0], [7, 0]])).toEqual [marker2] - - it "allows intersectsBufferRange to be specified", -> - marker1 = markerLayer.markBufferRange([[5, 0], [5, 0]], class: 'a') - marker2 = markerLayer.markBufferRange([[8, 0], [8, 0]], class: 'a') - displayLayer.foldBufferRange([[4, 0], [7, 0]]) - expect(markerLayer.findMarkers(class: 'a', intersectsBufferRange: [[5, 0], [6, 0]])).toEqual [marker1] - - it "allows intersectsScreenRange to be specified", -> - marker1 = markerLayer.markBufferRange([[5, 0], [5, 0]], class: 'a') - marker2 = markerLayer.markBufferRange([[8, 0], [8, 0]], class: 'a') - displayLayer.foldBufferRange([[4, 0], [7, 0]]) - expect(markerLayer.findMarkers(class: 'a', intersectsScreenRange: [[5, 0], [10, 0]])).toEqual [marker2] - - it "allows containsBufferPosition to be specified", -> - marker1 = markerLayer.markBufferRange([[5, 0], [5, 0]], class: 'a') - marker2 = markerLayer.markBufferRange([[8, 0], [8, 0]], class: 'a') - displayLayer.foldBufferRange([[4, 0], [7, 0]]) - expect(markerLayer.findMarkers(class: 'a', containsBufferPosition: [8, 0])).toEqual [marker2] - - it "allows containsScreenPosition to be specified", -> - marker1 = markerLayer.markBufferRange([[5, 0], [5, 0]], class: 'a') - marker2 = markerLayer.markBufferRange([[8, 0], [8, 0]], class: 'a') - displayLayer.foldBufferRange([[4, 0], [7, 0]]) - expect(markerLayer.findMarkers(class: 'a', containsScreenPosition: [5, 0])).toEqual [marker2] - - it "allows containsBufferRange to be specified", -> - marker1 = markerLayer.markBufferRange([[5, 0], [5, 10]], class: 'a') - marker2 = markerLayer.markBufferRange([[8, 0], [8, 10]], class: 'a') - displayLayer.foldBufferRange([[4, 0], [7, 0]]) - expect(markerLayer.findMarkers(class: 'a', containsBufferRange: [[8, 2], [8, 4]])).toEqual [marker2] - - it "allows containsScreenRange to be specified", -> - marker1 = markerLayer.markBufferRange([[5, 0], [5, 10]], class: 'a') - marker2 = markerLayer.markBufferRange([[8, 0], [8, 10]], class: 'a') - displayLayer.foldBufferRange([[4, 0], [7, 0]]) - expect(markerLayer.findMarkers(class: 'a', containsScreenRange: [[5, 2], [5, 4]])).toEqual [marker2] - - it "works when used from within a Marker.onDidDestroy callback (regression)", -> - displayMarker = markerLayer.markBufferRange([[0, 3], [0, 6]]) - displayMarker.onDidDestroy -> - expect(markerLayer.findMarkers({containsBufferPosition: [0, 4]})).not.toContain(displayMarker) - displayMarker.destroy() diff --git a/spec/display-marker-layer-spec.js b/spec/display-marker-layer-spec.js new file mode 100644 index 0000000000..ad05480c64 --- /dev/null +++ b/spec/display-marker-layer-spec.js @@ -0,0 +1,438 @@ +const TextBuffer = require('../src/text-buffer'); +const Point = require('../src/point'); +const Range = require('../src/range'); +const SampleText = require('./helpers/sample-text'); + +describe("DisplayMarkerLayer", function() { + beforeEach(() => jasmine.addCustomEqualityTester(require("underscore-plus").isEqual)); + + it("allows DisplayMarkers to be created and manipulated in screen coordinates", function() { + const buffer = new TextBuffer({text: 'abc\ndef\nghi\nj\tk\tl\nmno'}); + const displayLayer = buffer.addDisplayLayer({tabLength: 4}); + const markerLayer = displayLayer.addMarkerLayer(); + + const marker = markerLayer.markScreenRange([[3, 4], [4, 2]]); + expect(marker.getScreenRange()).toEqual([[3, 4], [4, 2]]); + expect(marker.getBufferRange()).toEqual([[3, 2], [4, 2]]); + + let markerChangeEvents = []; + marker.onDidChange(change => markerChangeEvents.push(change)); + + marker.setScreenRange([[3, 8], [4, 3]]); + + expect(marker.getBufferRange()).toEqual([[3, 4], [4, 3]]); + expect(marker.getScreenRange()).toEqual([[3, 8], [4, 3]]); + expect(markerChangeEvents[0]).toEqual({ + oldHeadBufferPosition: [4, 2], + newHeadBufferPosition: [4, 3], + oldTailBufferPosition: [3, 2], + newTailBufferPosition: [3, 4], + oldHeadScreenPosition: [4, 2], + newHeadScreenPosition: [4, 3], + oldTailScreenPosition: [3, 4], + newTailScreenPosition: [3, 8], + wasValid: true, + isValid: true, + textChanged: false + }); + + markerChangeEvents = []; + buffer.insert([4, 0], '\t'); + + expect(marker.getBufferRange()).toEqual([[3, 4], [4, 4]]); + expect(marker.getScreenRange()).toEqual([[3, 8], [4, 7]]); + expect(markerChangeEvents[0]).toEqual({ + oldHeadBufferPosition: [4, 3], + newHeadBufferPosition: [4, 4], + oldTailBufferPosition: [3, 4], + newTailBufferPosition: [3, 4], + oldHeadScreenPosition: [4, 3], + newHeadScreenPosition: [4, 7], + oldTailScreenPosition: [3, 8], + newTailScreenPosition: [3, 8], + wasValid: true, + isValid: true, + textChanged: true + }); + + expect(markerLayer.getMarker(marker.id)).toBe(marker); + + markerChangeEvents = []; + const foldId = displayLayer.foldBufferRange([[0, 2], [2, 2]]); + + expect(marker.getBufferRange()).toEqual([[3, 4], [4, 4]]); + expect(marker.getScreenRange()).toEqual([[1, 8], [2, 7]]); + expect(markerChangeEvents[0]).toEqual({ + oldHeadBufferPosition: [4, 4], + newHeadBufferPosition: [4, 4], + oldTailBufferPosition: [3, 4], + newTailBufferPosition: [3, 4], + oldHeadScreenPosition: [4, 7], + newHeadScreenPosition: [2, 7], + oldTailScreenPosition: [3, 8], + newTailScreenPosition: [1, 8], + wasValid: true, + isValid: true, + textChanged: false + }); + + markerChangeEvents = []; + displayLayer.destroyFold(foldId); + + expect(marker.getBufferRange()).toEqual([[3, 4], [4, 4]]); + expect(marker.getScreenRange()).toEqual([[3, 8], [4, 7]]); + expect(markerChangeEvents[0]).toEqual({ + oldHeadBufferPosition: [4, 4], + newHeadBufferPosition: [4, 4], + oldTailBufferPosition: [3, 4], + newTailBufferPosition: [3, 4], + oldHeadScreenPosition: [2, 7], + newHeadScreenPosition: [4, 7], + oldTailScreenPosition: [1, 8], + newTailScreenPosition: [3, 8], + wasValid: true, + isValid: true, + textChanged: false + }); + + markerChangeEvents = []; + displayLayer.reset({tabLength: 3}); + + expect(marker.getBufferRange()).toEqual([[3, 4], [4, 4]]); + expect(marker.getScreenRange()).toEqual([[3, 6], [4, 6]]); + expect(markerChangeEvents[0]).toEqual({ + oldHeadBufferPosition: [4, 4], + newHeadBufferPosition: [4, 4], + oldTailBufferPosition: [3, 4], + newTailBufferPosition: [3, 4], + oldHeadScreenPosition: [4, 7], + newHeadScreenPosition: [4, 6], + oldTailScreenPosition: [3, 8], + newTailScreenPosition: [3, 6], + wasValid: true, + isValid: true, + textChanged: false + }); +}); + + it("does not create duplicate DisplayMarkers when it has onDidCreateMarker observers (regression)", function() { + const buffer = new TextBuffer({text: 'abc\ndef\nghi\nj\tk\tl\nmno'}); + const displayLayer = buffer.addDisplayLayer({tabLength: 4}); + const markerLayer = displayLayer.addMarkerLayer(); + + let emittedMarker = null; + markerLayer.onDidCreateMarker(marker => emittedMarker = marker); + + const createdMarker = markerLayer.markBufferRange([[0, 1], [2, 3]]); + expect(createdMarker).toBe(emittedMarker); + }); + + it("emits events when markers are created and destroyed", function() { + const buffer = new TextBuffer({text: 'hello world'}); + const displayLayer = buffer.addDisplayLayer({tabLength: 4}); + const markerLayer = displayLayer.addMarkerLayer(); + const createdMarkers = []; + markerLayer.onDidCreateMarker(m => createdMarkers.push(m)); + const marker = markerLayer.markScreenRange([[0, 4], [1, 4]]); + + expect(createdMarkers).toEqual([marker]); + + let destroyEventCount = 0; + marker.onDidDestroy(() => destroyEventCount++); + + marker.destroy(); + expect(destroyEventCount).toBe(1); + }); + + it("emits update events when markers are created, updated directly, updated indirectly, or destroyed", function(done) { + const buffer = new TextBuffer({text: 'hello world'}); + const displayLayer = buffer.addDisplayLayer({tabLength: 4}); + const markerLayer = displayLayer.addMarkerLayer(); + let marker = null; + + let updateEventCount = 0; + markerLayer.onDidUpdate(function() { + updateEventCount++; + if (updateEventCount === 1) { + marker.setScreenRange([[0, 5], [1, 0]]); + } else if (updateEventCount === 2) { + buffer.insert([0, 0], '\t'); + } else if (updateEventCount === 3) { + marker.destroy(); + } else if (updateEventCount === 4) { + done(); + } + }); + + buffer.transact(() => marker = markerLayer.markScreenRange([[0, 4], [1, 4]])); + }); + + it("allows markers to be copied", function() { + const buffer = new TextBuffer({text: '\ta\tbc\tdef\tg\n\th'}); + const displayLayer = buffer.addDisplayLayer({tabLength: 4}); + const markerLayer = displayLayer.addMarkerLayer(); + + const markerA = markerLayer.markScreenRange([[0, 4], [1, 4]], {a: 1, b: 2}); + const markerB = markerA.copy({b: 3, c: 4}); + + expect(markerB.id).not.toBe(markerA.id); + expect(markerB.getProperties()).toEqual({a: 1, b: 3, c: 4}); + expect(markerB.getScreenRange()).toEqual(markerA.getScreenRange()); + }); + + describe("::destroy()", function() { + it("only destroys the underlying buffer MarkerLayer if the DisplayMarkerLayer was created by calling addMarkerLayer on its parent DisplayLayer", function() { + const buffer = new TextBuffer({text: 'abc\ndef\nghi\nj\tk\tl\nmno'}); + const displayLayer1 = buffer.addDisplayLayer({tabLength: 2}); + const displayLayer2 = buffer.addDisplayLayer({tabLength: 4}); + const bufferMarkerLayer = buffer.addMarkerLayer(); + const bufferMarker1 = bufferMarkerLayer.markRange([[2, 1], [2, 2]]); + const displayMarkerLayer1 = displayLayer1.getMarkerLayer(bufferMarkerLayer.id); + const displayMarker1 = displayMarkerLayer1.markBufferRange([[1, 0], [1, 2]]); + const displayMarkerLayer2 = displayLayer2.getMarkerLayer(bufferMarkerLayer.id); + const displayMarker2 = displayMarkerLayer2.markBufferRange([[2, 0], [2, 1]]); + const displayMarkerLayer3 = displayLayer2.addMarkerLayer(); + const displayMarker3 = displayMarkerLayer3.markBufferRange([[0, 0], [0, 0]]); + + let displayMarkerLayer1DestroyEventCount = 0; + displayMarkerLayer1.onDidDestroy(() => displayMarkerLayer1DestroyEventCount++); + let displayMarkerLayer2DestroyEventCount = 0; + displayMarkerLayer2.onDidDestroy(() => displayMarkerLayer2DestroyEventCount++); + let displayMarkerLayer3DestroyEventCount = 0; + displayMarkerLayer3.onDidDestroy(() => displayMarkerLayer3DestroyEventCount++); + + displayMarkerLayer1.destroy(); + expect(bufferMarkerLayer.isDestroyed()).toBe(false); + expect(displayMarkerLayer1.isDestroyed()).toBe(true); + expect(displayMarkerLayer1DestroyEventCount).toBe(1); + expect(bufferMarker1.isDestroyed()).toBe(false); + expect(displayMarker1.isDestroyed()).toBe(true); + expect(displayMarker2.isDestroyed()).toBe(false); + expect(displayMarker3.isDestroyed()).toBe(false); + + displayMarkerLayer2.destroy(); + expect(bufferMarkerLayer.isDestroyed()).toBe(false); + expect(displayMarkerLayer2.isDestroyed()).toBe(true); + expect(displayMarkerLayer2DestroyEventCount).toBe(1); + expect(bufferMarker1.isDestroyed()).toBe(false); + expect(displayMarker1.isDestroyed()).toBe(true); + expect(displayMarker2.isDestroyed()).toBe(true); + expect(displayMarker3.isDestroyed()).toBe(false); + + bufferMarkerLayer.destroy(); + expect(bufferMarkerLayer.isDestroyed()).toBe(true); + expect(displayMarkerLayer1DestroyEventCount).toBe(1); + expect(displayMarkerLayer2DestroyEventCount).toBe(1); + expect(bufferMarker1.isDestroyed()).toBe(true); + expect(displayMarker1.isDestroyed()).toBe(true); + expect(displayMarker2.isDestroyed()).toBe(true); + expect(displayMarker3.isDestroyed()).toBe(false); + + displayMarkerLayer3.destroy(); + expect(displayMarkerLayer3.bufferMarkerLayer.isDestroyed()).toBe(true); + expect(displayMarkerLayer3.isDestroyed()).toBe(true); + expect(displayMarkerLayer3DestroyEventCount).toBe(1); + expect(displayMarker3.isDestroyed()).toBe(true); + }); + + it("destroys the layer's markers", function() { + const buffer = new TextBuffer(); + const displayLayer = buffer.addDisplayLayer(); + const displayMarkerLayer = displayLayer.addMarkerLayer(); + + const marker1 = displayMarkerLayer.markBufferRange([[0, 0], [0, 0]]); + const marker2 = displayMarkerLayer.markBufferRange([[0, 0], [0, 0]]); + + const destroyListener = jasmine.createSpy('onDidDestroy listener'); + marker1.onDidDestroy(destroyListener); + + displayMarkerLayer.destroy(); + + expect(destroyListener).toHaveBeenCalled(); + expect(marker1.isDestroyed()).toBe(true); + + // Markers states are updated regardless of whether they have an + // ::onDidDestroy listener + expect(marker2.isDestroyed()).toBe(true); + }); + }); + + it("destroys display markers when their underlying buffer markers are destroyed", function() { + const buffer = new TextBuffer({text: '\tabc'}); + const displayLayer1 = buffer.addDisplayLayer({tabLength: 2}); + const displayLayer2 = buffer.addDisplayLayer({tabLength: 4}); + const bufferMarkerLayer = buffer.addMarkerLayer(); + const displayMarkerLayer1 = displayLayer1.getMarkerLayer(bufferMarkerLayer.id); + const displayMarkerLayer2 = displayLayer2.getMarkerLayer(bufferMarkerLayer.id); + + const bufferMarker = bufferMarkerLayer.markRange([[0, 1], [0, 2]]); + + const displayMarker1 = displayMarkerLayer1.getMarker(bufferMarker.id); + const displayMarker2 = displayMarkerLayer2.getMarker(bufferMarker.id); + expect(displayMarker1.getScreenRange()).toEqual([[0, 2], [0, 3]]); + expect(displayMarker2.getScreenRange()).toEqual([[0, 4], [0, 5]]); + + let displayMarker1DestroyCount = 0; + let displayMarker2DestroyCount = 0; + displayMarker1.onDidDestroy(() => displayMarker1DestroyCount++); + displayMarker2.onDidDestroy(() => displayMarker2DestroyCount++); + + bufferMarker.destroy(); + expect(displayMarker1DestroyCount).toBe(1); + expect(displayMarker2DestroyCount).toBe(1); + }); + + it("does not throw exceptions when buffer markers are destroyed that don't have corresponding display markers", function() { + const buffer = new TextBuffer({text: '\tabc'}); + const displayLayer1 = buffer.addDisplayLayer({tabLength: 2}); + const displayLayer2 = buffer.addDisplayLayer({tabLength: 4}); + const bufferMarkerLayer = buffer.addMarkerLayer(); + const displayMarkerLayer1 = displayLayer1.getMarkerLayer(bufferMarkerLayer.id); + const displayMarkerLayer2 = displayLayer2.getMarkerLayer(bufferMarkerLayer.id); + + const bufferMarker = bufferMarkerLayer.markRange([[0, 1], [0, 2]]); + bufferMarker.destroy(); + }); + + it("destroys itself when the underlying buffer marker layer is destroyed", function() { + const buffer = new TextBuffer({text: 'abc\ndef\nghi\nj\tk\tl\nmno'}); + const displayLayer1 = buffer.addDisplayLayer({tabLength: 2}); + const displayLayer2 = buffer.addDisplayLayer({tabLength: 4}); + + const bufferMarkerLayer = buffer.addMarkerLayer(); + const displayMarkerLayer1 = displayLayer1.getMarkerLayer(bufferMarkerLayer.id); + const displayMarkerLayer2 = displayLayer2.getMarkerLayer(bufferMarkerLayer.id); + let displayMarkerLayer1DestroyEventCount = 0; + displayMarkerLayer1.onDidDestroy(() => displayMarkerLayer1DestroyEventCount++); + let displayMarkerLayer2DestroyEventCount = 0; + displayMarkerLayer2.onDidDestroy(() => displayMarkerLayer2DestroyEventCount++); + + bufferMarkerLayer.destroy(); + expect(displayMarkerLayer1.isDestroyed()).toBe(true); + expect(displayMarkerLayer1DestroyEventCount).toBe(1); + expect(displayMarkerLayer2.isDestroyed()).toBe(true); + expect(displayMarkerLayer2DestroyEventCount).toBe(1); + }); + + describe("findMarkers(params)", function() { + let markerLayer, displayLayer; + + beforeEach(function() { + const buffer = new TextBuffer({text: SampleText}); + displayLayer = buffer.addDisplayLayer({tabLength: 4}); + markerLayer = displayLayer.addMarkerLayer(); + }); + + it("allows the startBufferRow and endBufferRow to be specified", function() { + const marker1 = markerLayer.markBufferRange([[0, 0], [3, 0]], {class: 'a'}); + const marker2 = markerLayer.markBufferRange([[0, 0], [5, 0]], {class: 'a'}); + const marker3 = markerLayer.markBufferRange([[9, 0], [10, 0]], {class: 'b'}); + + expect(markerLayer.findMarkers({class: 'a', startBufferRow: 0})).toEqual([marker2, marker1]); + expect(markerLayer.findMarkers({class: 'a', startBufferRow: 0, endBufferRow: 3})).toEqual([marker1]); + expect(markerLayer.findMarkers({endBufferRow: 10})).toEqual([marker3]); + }); + + it("allows the startScreenRow and endScreenRow to be specified", function() { + const marker1 = markerLayer.markBufferRange([[6, 0], [7, 0]], {class: 'a'}); + const marker2 = markerLayer.markBufferRange([[9, 0], [10, 0]], {class: 'a'}); + displayLayer.foldBufferRange([[4, 0], [7, 0]]); + expect(markerLayer.findMarkers({class: 'a', startScreenRow: 6, endScreenRow: 7})).toEqual([marker2]); + + displayLayer.destroyFoldsIntersectingBufferRange([[4, 0], [7, 0]]); + displayLayer.foldBufferRange([[0, 20], [12, 2]]); + const marker3 = markerLayer.markBufferRange([[12, 0], [12, 0]], {class: 'a'}); + expect(markerLayer.findMarkers({class: 'a', startScreenRow: 0})).toEqual([marker1, marker2, marker3]); + expect(markerLayer.findMarkers({class: 'a', endScreenRow: 0})).toEqual([marker1, marker2, marker3]); + }); + + it("allows the startsInBufferRange/endsInBufferRange and startsInScreenRange/endsInScreenRange to be specified", function() { + const marker1 = markerLayer.markBufferRange([[5, 2], [5, 4]], {class: 'a'}); + const marker2 = markerLayer.markBufferRange([[8, 0], [8, 2]], {class: 'a'}); + displayLayer.foldBufferRange([[4, 0], [7, 0]]); + expect(markerLayer.findMarkers({class: 'a', startsInBufferRange: [[5, 1], [5, 3]]})).toEqual([marker1]); + expect(markerLayer.findMarkers({class: 'a', endsInBufferRange: [[8, 1], [8, 3]]})).toEqual([marker2]); + expect(markerLayer.findMarkers({class: 'a', startsInScreenRange: [[4, 0], [4, 1]]})).toEqual([marker1]); + expect(markerLayer.findMarkers({class: 'a', endsInScreenRange: [[5, 1], [5, 3]]})).toEqual([marker2]); + }); + + it("allows intersectsBufferRowRange to be specified", function() { + const marker1 = markerLayer.markBufferRange([[5, 0], [5, 0]], {class: 'a'}); + const marker2 = markerLayer.markBufferRange([[8, 0], [8, 0]], {class: 'a'}); + displayLayer.foldBufferRange([[4, 0], [7, 0]]); + expect(markerLayer.findMarkers({class: 'a', intersectsBufferRowRange: [5, 6]})).toEqual([marker1]); + }); + + it("allows intersectsScreenRowRange to be specified", function() { + const marker1 = markerLayer.markBufferRange([[5, 0], [5, 0]], {class: 'a'}); + const marker2 = markerLayer.markBufferRange([[8, 0], [8, 0]], {class: 'a'}); + displayLayer.foldBufferRange([[4, 0], [7, 0]]); + expect(markerLayer.findMarkers({class: 'a', intersectsScreenRowRange: [5, 10]})).toEqual([marker2]); + + displayLayer.destroyAllFolds(); + displayLayer.foldBufferRange([[0, 20], [12, 2]]); + expect(markerLayer.findMarkers({class: 'a', intersectsScreenRowRange: [0, 0]})).toEqual([marker1, marker2]); + + displayLayer.destroyAllFolds(); + displayLayer.reset({softWrapColumn: 10}); + marker1.setHeadScreenPosition([6, 5]); + marker2.setHeadScreenPosition([9, 2]); + expect(markerLayer.findMarkers({class: 'a', intersectsScreenRowRange: [5, 7]})).toEqual([marker1]); + }); + + it("allows containedInScreenRange to be specified", function() { + const marker1 = markerLayer.markBufferRange([[5, 0], [5, 0]], {class: 'a'}); + const marker2 = markerLayer.markBufferRange([[8, 0], [8, 0]], {class: 'a'}); + displayLayer.foldBufferRange([[4, 0], [7, 0]]); + expect(markerLayer.findMarkers({class: 'a', containedInScreenRange: [[5, 0], [7, 0]]})).toEqual([marker2]); + }); + + it("allows intersectsBufferRange to be specified", function() { + const marker1 = markerLayer.markBufferRange([[5, 0], [5, 0]], {class: 'a'}); + const marker2 = markerLayer.markBufferRange([[8, 0], [8, 0]], {class: 'a'}); + displayLayer.foldBufferRange([[4, 0], [7, 0]]); + expect(markerLayer.findMarkers({class: 'a', intersectsBufferRange: [[5, 0], [6, 0]]})).toEqual([marker1]); + }); + + it("allows intersectsScreenRange to be specified", function() { + const marker1 = markerLayer.markBufferRange([[5, 0], [5, 0]], {class: 'a'}); + const marker2 = markerLayer.markBufferRange([[8, 0], [8, 0]], {class: 'a'}); + displayLayer.foldBufferRange([[4, 0], [7, 0]]); + expect(markerLayer.findMarkers({class: 'a', intersectsScreenRange: [[5, 0], [10, 0]]})).toEqual([marker2]); + }); + + it("allows containsBufferPosition to be specified", function() { + const marker1 = markerLayer.markBufferRange([[5, 0], [5, 0]], {class: 'a'}); + const marker2 = markerLayer.markBufferRange([[8, 0], [8, 0]], {class: 'a'}); + displayLayer.foldBufferRange([[4, 0], [7, 0]]); + expect(markerLayer.findMarkers({class: 'a', containsBufferPosition: [8, 0]})).toEqual([marker2]); + }); + + it("allows containsScreenPosition to be specified", function() { + const marker1 = markerLayer.markBufferRange([[5, 0], [5, 0]], {class: 'a'}); + const marker2 = markerLayer.markBufferRange([[8, 0], [8, 0]], {class: 'a'}); + displayLayer.foldBufferRange([[4, 0], [7, 0]]); + expect(markerLayer.findMarkers({class: 'a', containsScreenPosition: [5, 0]})).toEqual([marker2]); + }); + + it("allows containsBufferRange to be specified", function() { + const marker1 = markerLayer.markBufferRange([[5, 0], [5, 10]], {class: 'a'}); + const marker2 = markerLayer.markBufferRange([[8, 0], [8, 10]], {class: 'a'}); + displayLayer.foldBufferRange([[4, 0], [7, 0]]); + expect(markerLayer.findMarkers({class: 'a', containsBufferRange: [[8, 2], [8, 4]]})).toEqual([marker2]); + }); + + it("allows containsScreenRange to be specified", function() { + const marker1 = markerLayer.markBufferRange([[5, 0], [5, 10]], {class: 'a'}); + const marker2 = markerLayer.markBufferRange([[8, 0], [8, 10]], {class: 'a'}); + displayLayer.foldBufferRange([[4, 0], [7, 0]]); + expect(markerLayer.findMarkers({class: 'a', containsScreenRange: [[5, 2], [5, 4]]})).toEqual([marker2]); + }); + + it("works when used from within a Marker.onDidDestroy callback (regression)", function() { + const displayMarker = markerLayer.markBufferRange([[0, 3], [0, 6]]); + displayMarker.onDidDestroy(() => expect(markerLayer.findMarkers({containsBufferPosition: [0, 4]})).not.toContain(displayMarker)); + displayMarker.destroy(); + }); + }); +}); diff --git a/spec/helpers/coffee.js b/spec/helpers/coffee.js index f0dbe00e86..e69de29bb2 100644 --- a/spec/helpers/coffee.js +++ b/spec/helpers/coffee.js @@ -1 +0,0 @@ -require('coffee-cache') diff --git a/spec/helpers/test-language-mode.js b/spec/helpers/test-language-mode.js index c229524054..2eb3e01e49 100644 --- a/spec/helpers/test-language-mode.js +++ b/spec/helpers/test-language-mode.js @@ -1,4 +1,4 @@ -const {MarkerIndex} = require('superstring') +const {MarkerIndex} = require('@pulsar-edit/superstring') const {Emitter} = require('event-kit') const Point = require('../../src/point') const Range = require('../../src/range') diff --git a/spec/marker-layer-spec.coffee b/spec/marker-layer-spec.coffee deleted file mode 100644 index faeabbf597..0000000000 --- a/spec/marker-layer-spec.coffee +++ /dev/null @@ -1,368 +0,0 @@ -{uniq, times} = require 'underscore-plus' -TextBuffer = require '../src/text-buffer' - -describe "MarkerLayer", -> - [buffer, layer1, layer2] = [] - - beforeEach -> - jasmine.addCustomEqualityTester(require("underscore-plus").isEqual) - buffer = new TextBuffer(text: "abcdefghijklmnopqrstuvwxyz") - layer1 = buffer.addMarkerLayer() - layer2 = buffer.addMarkerLayer() - - it "ensures that marker ids are unique across layers", -> - times 5, -> - buffer.markRange([[0, 3], [0, 6]]) - layer1.markRange([[0, 4], [0, 7]]) - layer2.markRange([[0, 5], [0, 8]]) - - ids = buffer.getMarkers() - .concat(layer1.getMarkers()) - .concat(layer2.getMarkers()) - .map (marker) -> marker.id - - expect(uniq(ids).length).toEqual ids.length - - it "updates each layer's markers when the text changes", -> - defaultMarker = buffer.markRange([[0, 3], [0, 6]]) - layer1Marker = layer1.markRange([[0, 4], [0, 7]]) - layer2Marker = layer2.markRange([[0, 5], [0, 8]]) - - buffer.setTextInRange([[0, 1], [0, 2]], "BBB") - expect(defaultMarker.getRange()).toEqual [[0, 5], [0, 8]] - expect(layer1Marker.getRange()).toEqual [[0, 6], [0, 9]] - expect(layer2Marker.getRange()).toEqual [[0, 7], [0, 10]] - - layer2.destroy() - expect(layer2.isAlive()).toBe false - expect(layer2.isDestroyed()).toBe true - - expect(layer1.isAlive()).toBe true - expect(layer1.isDestroyed()).toBe false - - buffer.undo() - expect(defaultMarker.getRange()).toEqual [[0, 3], [0, 6]] - expect(layer1Marker.getRange()).toEqual [[0, 4], [0, 7]] - - expect(layer2Marker.isDestroyed()).toBe true - expect(layer2Marker.getRange()).toEqual [[0, 0], [0, 0]] - - it "emits onDidCreateMarker events synchronously when markers are created", -> - createdMarkers = [] - layer1.onDidCreateMarker (marker) -> createdMarkers.push(marker) - marker = layer1.markRange([[0, 1], [2, 3]]) - expect(createdMarkers).toEqual [marker] - - it "does not emit marker events on the TextBuffer for non-default layers", -> - createEventCount = updateEventCount = 0 - buffer.onDidCreateMarker -> createEventCount++ - buffer.onDidUpdateMarkers -> updateEventCount++ - - marker1 = buffer.markRange([[0, 1], [0, 2]]) - marker1.setRange([[0, 1], [0, 3]]) - - expect(createEventCount).toBe 1 - expect(updateEventCount).toBe 2 - - marker2 = layer1.markRange([[0, 1], [0, 2]]) - marker2.setRange([[0, 1], [0, 3]]) - - expect(createEventCount).toBe 1 - expect(updateEventCount).toBe 2 - - describe "when destroyInvalidatedMarkers is enabled for the layer", -> - it "destroys markers when they are invalidated via a splice", -> - layer3 = buffer.addMarkerLayer(destroyInvalidatedMarkers: true) - - marker1 = layer3.markRange([[0, 0], [0, 3]], invalidate: 'inside') - marker2 = layer3.markRange([[0, 2], [0, 6]], invalidate: 'inside') - - destroyedMarkers = [] - marker1.onDidDestroy -> destroyedMarkers.push(marker1) - marker2.onDidDestroy -> destroyedMarkers.push(marker2) - - buffer.insert([0, 5], 'x') - - expect(destroyedMarkers).toEqual [marker2] - expect(marker2.isDestroyed()).toBe true - expect(marker1.isDestroyed()).toBe false - - describe "when maintainHistory is enabled for the layer", -> - layer3 = null - - beforeEach -> - layer3 = buffer.addMarkerLayer(maintainHistory: true) - - it "restores the state of all markers in the layer on undo and redo", -> - buffer.setText('') - buffer.transact -> buffer.append('foo') - layer3 = buffer.addMarkerLayer(maintainHistory: true) - - marker1 = layer3.markRange([[0, 0], [0, 0]], a: 'b', invalidate: 'never') - marker2 = layer3.markRange([[0, 0], [0, 0]], c: 'd', invalidate: 'never') - - marker2ChangeCount = 0 - marker2.onDidChange -> marker2ChangeCount++ - - buffer.transact -> - buffer.append('\n') - buffer.append('bar') - - marker1.destroy() - marker2.setRange([[0, 2], [0, 3]]) - marker3 = layer3.markRange([[0, 0], [0, 3]], e: 'f', invalidate: 'never') - marker4 = layer3.markRange([[1, 0], [1, 3]], g: 'h', invalidate: 'never') - expect(marker2ChangeCount).toBe(1) - - createdMarker = null - layer3.onDidCreateMarker((m) -> createdMarker = m) - buffer.undo() - - expect(buffer.getText()).toBe 'foo' - expect(marker1.isDestroyed()).toBe false - expect(createdMarker).toBe(marker1) - markers = layer3.findMarkers({}) - expect(markers.length).toBe 2 - expect(markers[0]).toBe marker1 - expect(markers[0].getProperties()).toEqual {a: 'b'} - expect(markers[0].getRange()).toEqual [[0, 0], [0, 0]] - expect(markers[1].getProperties()).toEqual {c: 'd'} - expect(markers[1].getRange()).toEqual [[0, 0], [0, 0]] - expect(marker2ChangeCount).toBe(2) - - buffer.redo() - - expect(buffer.getText()).toBe 'foo\nbar' - markers = layer3.findMarkers({}) - expect(markers.length).toBe 3 - expect(markers[0].getProperties()).toEqual {e: 'f'} - expect(markers[0].getRange()).toEqual [[0, 0], [0, 3]] - expect(markers[1].getProperties()).toEqual {c: 'd'} - expect(markers[1].getRange()).toEqual [[0, 2], [0, 3]] - expect(markers[2].getProperties()).toEqual {g: 'h'} - expect(markers[2].getRange()).toEqual [[1, 0], [1, 3]] - - it "does not undo marker manipulations that aren't associated with text changes", -> - marker = layer3.markRange([[0, 6], [0, 9]]) - - # Can't undo changes in a transaction without other buffer changes - buffer.transact -> marker.setRange([[0, 4], [0, 20]]) - buffer.undo() - expect(marker.getRange()).toEqual [[0, 4], [0, 20]] - - # Can undo changes in a transaction with other buffer changes - buffer.transact -> - marker.setRange([[0, 5], [0, 9]]) - buffer.setTextInRange([[0, 2], [0, 3]], 'XYZ') - marker.setRange([[0, 8], [0, 12]]) - - buffer.undo() - expect(marker.getRange()).toEqual [[0, 4], [0, 20]] - - buffer.redo() - expect(marker.getRange()).toEqual [[0, 8], [0, 12]] - - it "ignores snapshot references to marker layers that no longer exist", -> - layer3.markRange([[0, 6], [0, 9]]) - buffer.append("stuff") - layer3.destroy() - - # Should not throw an exception - buffer.undo() - - describe "when a role is provided for the layer", -> - it "getRole() returns its role and keeps track of ids of 'selections' role", -> - expect(buffer.selectionsMarkerLayerIds.size).toBe 0 - - selectionsMarkerLayer1 = buffer.addMarkerLayer(role: "selections") - expect(selectionsMarkerLayer1.getRole()).toBe "selections" - - expect(buffer.addMarkerLayer(role: "role-1").getRole()).toBe "role-1" - expect(buffer.addMarkerLayer().getRole()).toBe undefined - - expect(buffer.selectionsMarkerLayerIds.size).toBe 1 - expect(buffer.selectionsMarkerLayerIds.has(selectionsMarkerLayer1.id)).toBe true - - selectionsMarkerLayer2 = buffer.addMarkerLayer(role: "selections") - expect(selectionsMarkerLayer2.getRole()).toBe "selections" - - expect(buffer.selectionsMarkerLayerIds.size).toBe 2 - expect(buffer.selectionsMarkerLayerIds.has(selectionsMarkerLayer2.id)).toBe true - - selectionsMarkerLayer1.destroy() - selectionsMarkerLayer2.destroy() - expect(buffer.selectionsMarkerLayerIds.size).toBe 2 - expect(buffer.selectionsMarkerLayerIds.has(selectionsMarkerLayer1.id)).toBe true - expect(buffer.selectionsMarkerLayerIds.has(selectionsMarkerLayer2.id)).toBe true - - describe "::findMarkers(params)", -> - it "does not find markers from other layers", -> - defaultMarker = buffer.markRange([[0, 3], [0, 6]]) - layer1Marker = layer1.markRange([[0, 3], [0, 6]]) - layer2Marker = layer2.markRange([[0, 3], [0, 6]]) - - expect(buffer.findMarkers(containsPoint: [0, 4])).toEqual [defaultMarker] - expect(layer1.findMarkers(containsPoint: [0, 4])).toEqual [layer1Marker] - expect(layer2.findMarkers(containsPoint: [0, 4])).toEqual [layer2Marker] - - describe "::onDidUpdate", -> - it "notifies observers at the end of the outermost transaction when markers are created, updated, or destroyed", -> - [marker1, marker2] = [] - - displayLayer = buffer.addDisplayLayer() - displayLayerDidChange = false - - changeCount = 0 - buffer.onDidChange -> - changeCount++ - - updateCount = 0 - layer1.onDidUpdate -> - updateCount++ - if updateCount is 1 - expect(changeCount).toBe(0) - buffer.transact -> - marker1.setRange([[1, 2], [3, 4]]) - marker2.setRange([[4, 5], [6, 7]]) - else if updateCount is 2 - expect(changeCount).toBe(0) - buffer.transact -> - buffer.insert([0, 1], "xxx") - buffer.insert([0, 1], "yyy") - else if updateCount is 3 - expect(changeCount).toBe(1) - marker1.destroy() - marker2.destroy() - else if updateCount is 7 - expect(changeCount).toBe(2) - expect(displayLayerDidChange).toBe(true, 'Display layer was updated after marker layer.') - - buffer.transact -> - buffer.transact -> - marker1 = layer1.markRange([[0, 2], [0, 4]]) - marker2 = layer1.markRange([[0, 6], [0, 8]]) - - expect(updateCount).toBe(5) - - # update events happen immediately when there is no parent transaction - layer1.markRange([[0, 2], [0, 4]]) - expect(updateCount).toBe(6) - - # update events happen after updating display layers when there is no parent transaction. - displayLayer.onDidChange -> - displayLayerDidChange = true - buffer.undo() - expect(updateCount).toBe(7) - - describe "::clear()", -> - it "destroys all of the layer's markers", (done) -> - buffer = new TextBuffer(text: 'abc') - displayLayer = buffer.addDisplayLayer() - markerLayer = buffer.addMarkerLayer() - displayMarkerLayer = displayLayer.getMarkerLayer(markerLayer.id) - marker1 = markerLayer.markRange([[0, 1], [0, 2]]) - marker2 = markerLayer.markRange([[0, 1], [0, 2]]) - marker3 = markerLayer.markRange([[0, 1], [0, 2]]) - displayMarker1 = displayMarkerLayer.getMarker(marker1.id) - # intentionally omit a display marker for marker2 just to cover that case - displayMarker3 = displayMarkerLayer.getMarker(marker3.id) - - marker1DestroyCount = 0 - marker2DestroyCount = 0 - displayMarker1DestroyCount = 0 - displayMarker3DestroyCount = 0 - markerLayerUpdateCount = 0 - displayMarkerLayerUpdateCount = 0 - marker1.onDidDestroy -> marker1DestroyCount++ - marker2.onDidDestroy -> marker2DestroyCount++ - displayMarker1.onDidDestroy -> displayMarker1DestroyCount++ - displayMarker3.onDidDestroy -> displayMarker3DestroyCount++ - markerLayer.onDidUpdate -> - markerLayerUpdateCount++ - done() if markerLayerUpdateCount is 1 and displayMarkerLayerUpdateCount is 1 - displayMarkerLayer.onDidUpdate -> - displayMarkerLayerUpdateCount++ - done() if markerLayerUpdateCount is 1 and displayMarkerLayerUpdateCount is 1 - - markerLayer.clear() - expect(marker1.isDestroyed()).toBe(true) - expect(marker2.isDestroyed()).toBe(true) - expect(marker3.isDestroyed()).toBe(true) - expect(displayMarker1.isDestroyed()).toBe(true) - expect(displayMarker3.isDestroyed()).toBe(true) - expect(marker1DestroyCount).toBe(1) - expect(marker2DestroyCount).toBe(1) - expect(displayMarker1DestroyCount).toBe(1) - expect(displayMarker3DestroyCount).toBe(1) - expect(markerLayer.getMarkers()).toEqual([]) - expect(displayMarkerLayer.getMarkers()).toEqual([]) - expect(displayMarkerLayer.getMarker(displayMarker3.id)).toBeUndefined() - - describe "::copy", -> - it "creates a new marker layer with markers in the same states", -> - originalLayer = buffer.addMarkerLayer(maintainHistory: true) - originalLayer.markRange([[0, 1], [0, 3]], a: 'b') - originalLayer.markPosition([0, 2]) - - copy = originalLayer.copy() - expect(copy).not.toBe originalLayer - - markers = copy.getMarkers() - expect(markers.length).toBe 2 - expect(markers[0].getRange()).toEqual [[0, 1], [0, 3]] - expect(markers[0].getProperties()).toEqual {a: 'b'} - expect(markers[1].getRange()).toEqual [[0, 2], [0, 2]] - expect(markers[1].hasTail()).toBe false - - it "copies the marker layer role", -> - originalLayer = buffer.addMarkerLayer(maintainHistory: true, role: "selections") - copy = originalLayer.copy() - expect(copy).not.toBe originalLayer - expect(copy.getRole()).toBe("selections") - expect(buffer.selectionsMarkerLayerIds.has(originalLayer.id)).toBe true - expect(buffer.selectionsMarkerLayerIds.has(copy.id)).toBe true - expect(buffer.selectionsMarkerLayerIds.size).toBe 2 - - describe "::destroy", -> - it "destroys the layer's markers", -> - buffer = new TextBuffer() - markerLayer = buffer.addMarkerLayer() - - marker1 = markerLayer.markRange([[0, 0], [0, 0]]) - marker2 = markerLayer.markRange([[0, 0], [0, 0]]) - - destroyListener = jasmine.createSpy('onDidDestroy listener') - marker1.onDidDestroy(destroyListener) - - markerLayer.destroy() - - expect(destroyListener).toHaveBeenCalled() - expect(marker1.isDestroyed()).toBe(true) - - # Markers states are updated regardless of whether they have an - # ::onDidDestroy listener - expect(marker2.isDestroyed()).toBe(true) - - describe "trackDestructionInOnDidCreateMarkerCallbacks", -> - it "stores a stack trace when destroy is called during onDidCreateMarker callbacks", -> - layer1.onDidCreateMarker (m) -> m.destroy() if destroyInCreateCallback - - layer1.trackDestructionInOnDidCreateMarkerCallbacks = true - destroyInCreateCallback = true - marker1 = layer1.markPosition([0, 0]) - expect(marker1.isDestroyed()).toBe(true) - expect(marker1.destroyStackTrace).toBeDefined() - - destroyInCreateCallback = false - marker2 = layer1.markPosition([0, 0]) - expect(marker2.isDestroyed()).toBe(false) - expect(marker2.destroyStackTrace).toBeUndefined() - marker2.destroy() - expect(marker2.isDestroyed()).toBe(true) - expect(marker2.destroyStackTrace).toBeUndefined() - - destroyInCreateCallback = true - layer1.trackDestructionInOnDidCreateMarkerCallbacks = false - marker3 = layer1.markPosition([0, 0]) - expect(marker3.isDestroyed()).toBe(true) - expect(marker3.destroyStackTrace).toBeUndefined() diff --git a/spec/marker-layer-spec.js b/spec/marker-layer-spec.js new file mode 100644 index 0000000000..cd5eca06ee --- /dev/null +++ b/spec/marker-layer-spec.js @@ -0,0 +1,397 @@ +const {uniq, times} = require('underscore-plus'); +const TextBuffer = require('../src/text-buffer'); + +describe("MarkerLayer", function() { + let buffer, layer1, layer2; + + beforeEach(function() { + jasmine.addCustomEqualityTester(require("underscore-plus").isEqual); + buffer = new TextBuffer({text: "abcdefghijklmnopqrstuvwxyz"}); + layer1 = buffer.addMarkerLayer(); + layer2 = buffer.addMarkerLayer(); + }); + + it("ensures that marker ids are unique across layers", function() { + times(5, function() { + buffer.markRange([[0, 3], [0, 6]]); + layer1.markRange([[0, 4], [0, 7]]); + layer2.markRange([[0, 5], [0, 8]]); + }); + + const ids = buffer.getMarkers() + .concat(layer1.getMarkers()) + .concat(layer2.getMarkers()) + .map(marker => marker.id); + + expect(uniq(ids).length).toEqual(ids.length); + }); + + it("updates each layer's markers when the text changes", function() { + const defaultMarker = buffer.markRange([[0, 3], [0, 6]]); + const layer1Marker = layer1.markRange([[0, 4], [0, 7]]); + const layer2Marker = layer2.markRange([[0, 5], [0, 8]]); + + buffer.setTextInRange([[0, 1], [0, 2]], "BBB"); + expect(defaultMarker.getRange()).toEqual([[0, 5], [0, 8]]); + expect(layer1Marker.getRange()).toEqual([[0, 6], [0, 9]]); + expect(layer2Marker.getRange()).toEqual([[0, 7], [0, 10]]); + + layer2.destroy(); + expect(layer2.isAlive()).toBe(false); + expect(layer2.isDestroyed()).toBe(true); + + expect(layer1.isAlive()).toBe(true); + expect(layer1.isDestroyed()).toBe(false); + + buffer.undo(); + expect(defaultMarker.getRange()).toEqual([[0, 3], [0, 6]]); + expect(layer1Marker.getRange()).toEqual([[0, 4], [0, 7]]); + + expect(layer2Marker.isDestroyed()).toBe(true); + expect(layer2Marker.getRange()).toEqual([[0, 0], [0, 0]]); +}); + + it("emits onDidCreateMarker events synchronously when markers are created", function() { + const createdMarkers = []; + layer1.onDidCreateMarker(marker => createdMarkers.push(marker)); + const marker = layer1.markRange([[0, 1], [2, 3]]); + expect(createdMarkers).toEqual([marker]); +}); + + it("does not emit marker events on the TextBuffer for non-default layers", function() { + let updateEventCount; + let createEventCount = (updateEventCount = 0); + buffer.onDidCreateMarker(() => createEventCount++); + buffer.onDidUpdateMarkers(() => updateEventCount++); + + const marker1 = buffer.markRange([[0, 1], [0, 2]]); + marker1.setRange([[0, 1], [0, 3]]); + + expect(createEventCount).toBe(1); + expect(updateEventCount).toBe(2); + + const marker2 = layer1.markRange([[0, 1], [0, 2]]); + marker2.setRange([[0, 1], [0, 3]]); + + expect(createEventCount).toBe(1); + expect(updateEventCount).toBe(2); + }); + + describe("when destroyInvalidatedMarkers is enabled for the layer", () => { + it("destroys markers when they are invalidated via a splice", function() { + const layer3 = buffer.addMarkerLayer({destroyInvalidatedMarkers: true}); + + const marker1 = layer3.markRange([[0, 0], [0, 3]], {invalidate: 'inside'}); + const marker2 = layer3.markRange([[0, 2], [0, 6]], {invalidate: 'inside'}); + + const destroyedMarkers = []; + marker1.onDidDestroy(() => destroyedMarkers.push(marker1)); + marker2.onDidDestroy(() => destroyedMarkers.push(marker2)); + + buffer.insert([0, 5], 'x'); + + expect(destroyedMarkers).toEqual([marker2]); + expect(marker2.isDestroyed()).toBe(true); + expect(marker1.isDestroyed()).toBe(false); + }) + }); + + describe("when maintainHistory is enabled for the layer", function() { + let layer3 = null; + + beforeEach(() => layer3 = buffer.addMarkerLayer({maintainHistory: true})); + + it("restores the state of all markers in the layer on undo and redo", function() { + buffer.setText(''); + buffer.transact(() => buffer.append('foo')); + layer3 = buffer.addMarkerLayer({maintainHistory: true}); + + const marker1 = layer3.markRange([[0, 0], [0, 0]], {invalidate: 'never'}); + const marker2 = layer3.markRange([[0, 0], [0, 0]], {invalidate: 'never'}); + + let marker2ChangeCount = 0; + marker2.onDidChange(() => marker2ChangeCount++); + + + buffer.transact(function() { + buffer.append('\n'); + buffer.append('bar'); + + marker1.destroy(); + marker2.setRange([[0, 2], [0, 3]]); + const marker3 = layer3.markRange([[0, 0], [0, 3]], {invalidate: 'never'}); + const marker4 = layer3.markRange([[1, 0], [1, 3]], {invalidate: 'never'}); + expect(marker2ChangeCount).toBe(1); + }); + + let createdMarker = null; + layer3.onDidCreateMarker(m => createdMarker = m); + buffer.undo(); + + expect(buffer.getText()).toBe('foo'); + expect(marker1.isDestroyed()).toBe(false); + expect(createdMarker).toBe(marker1); + let markers = layer3.findMarkers({}); + expect(markers.length).toBe(2); + expect(markers[0]).toBe(marker1); + expect(markers[0].getRange()).toEqual([[0, 0], [0, 0]]); + expect(markers[1].getRange()).toEqual([[0, 0], [0, 0]]); + expect(marker2ChangeCount).toBe(2); + + buffer.redo(); + + expect(buffer.getText()).toBe('foo\nbar'); + markers = layer3.findMarkers({}); + expect(markers.length).toBe(3); + expect(markers[0].getRange()).toEqual([[0, 0], [0, 3]]); + expect(markers[1].getRange()).toEqual([[0, 2], [0, 3]]); + expect(markers[2].getRange()).toEqual([[1, 0], [1, 3]]); + }); + + it("does not undo marker manipulations that aren't associated with text changes", function() { + const marker = layer3.markRange([[0, 6], [0, 9]]); + + // Can't undo changes in a transaction without other buffer changes + buffer.transact(() => marker.setRange([[0, 4], [0, 20]])); + buffer.undo(); + expect(marker.getRange()).toEqual([[0, 4], [0, 20]]); + + // Can undo changes in a transaction with other buffer changes + buffer.transact(function() { + marker.setRange([[0, 5], [0, 9]]); + buffer.setTextInRange([[0, 2], [0, 3]], 'XYZ'); + marker.setRange([[0, 8], [0, 12]]); + }); + + buffer.undo(); + expect(marker.getRange()).toEqual([[0, 4], [0, 20]]); + + buffer.redo(); + expect(marker.getRange()).toEqual([[0, 8], [0, 12]]); + }); + + it("ignores snapshot references to marker layers that no longer exist", function() { + layer3.markRange([[0, 6], [0, 9]]); + buffer.append("stuff"); + layer3.destroy(); + + // Should not throw an exception + buffer.undo(); + }); + }); + + describe("when a role is provided for the layer", () => { + it("getRole() returns its role and keeps track of ids of 'selections' role", function() { + expect(buffer.selectionsMarkerLayerIds.size).toBe(0); + + const selectionsMarkerLayer1 = buffer.addMarkerLayer({role: "selections"}); + expect(selectionsMarkerLayer1.getRole()).toBe("selections"); + + expect(buffer.addMarkerLayer({role: "role-1"}).getRole()).toBe("role-1"); + expect(buffer.addMarkerLayer().getRole()).toBe(undefined); + + expect(buffer.selectionsMarkerLayerIds.size).toBe(1); + expect(buffer.selectionsMarkerLayerIds.has(selectionsMarkerLayer1.id)).toBe(true); + + const selectionsMarkerLayer2 = buffer.addMarkerLayer({role: "selections"}); + expect(selectionsMarkerLayer2.getRole()).toBe("selections"); + + expect(buffer.selectionsMarkerLayerIds.size).toBe(2); + expect(buffer.selectionsMarkerLayerIds.has(selectionsMarkerLayer2.id)).toBe(true); + + selectionsMarkerLayer1.destroy(); + selectionsMarkerLayer2.destroy(); + expect(buffer.selectionsMarkerLayerIds.size).toBe(2); + expect(buffer.selectionsMarkerLayerIds.has(selectionsMarkerLayer1.id)).toBe(true); + expect(buffer.selectionsMarkerLayerIds.has(selectionsMarkerLayer2.id)).toBe(true); + }) + }); + + describe("::findMarkers(params)", () => { + it("does not find markers from other layers", () => { + const defaultMarker = buffer.markRange([[0, 3], [0, 6]]); + const layer1Marker = layer1.markRange([[0, 3], [0, 6]]); + const layer2Marker = layer2.markRange([[0, 3], [0, 6]]); + + expect(buffer.findMarkers({containsPoint: [0, 4]})).toEqual([defaultMarker]); + expect(layer1.findMarkers({containsPoint: [0, 4]})).toEqual([layer1Marker]); + expect(layer2.findMarkers({containsPoint: [0, 4]})).toEqual([layer2Marker]); + }) + }); + + describe("::onDidUpdate", () => { + it("notifies observers at the end of the outermost transaction when markers are created, updated, or destroyed", function() { + let marker1, marker2; + + const displayLayer = buffer.addDisplayLayer(); + let displayLayerDidChange = false; + + let changeCount = 0; + buffer.onDidChange(() => changeCount++); + + let updateCount = 0; + layer1.onDidUpdate(function() { + updateCount++; + if (updateCount === 1) { + expect(changeCount).toBe(0); + buffer.transact(function() { + marker1.setRange([[1, 2], [3, 4]]); + marker2.setRange([[4, 5], [6, 7]]); + }); + } else if (updateCount === 2) { + expect(changeCount).toBe(0); + buffer.transact(function() { + buffer.insert([0, 1], "xxx"); + buffer.insert([0, 1], "yyy"); + }); + } else if (updateCount === 3) { + expect(changeCount).toBe(1); + marker1.destroy(); + marker2.destroy(); + } else if (updateCount === 7) { + expect(changeCount).toBe(2); + expect(displayLayerDidChange).toBe(true, 'Display layer was updated after marker layer.'); + } + }); + + buffer.transact(() => buffer.transact(function() { + marker1 = layer1.markRange([[0, 2], [0, 4]]); + marker2 = layer1.markRange([[0, 6], [0, 8]]); + })); + + expect(updateCount).toBe(5); + + // update events happen immediately when there is no parent transaction + layer1.markRange([[0, 2], [0, 4]]); + expect(updateCount).toBe(6); + + // update events happen after updating display layers when there is no parent transaction. + displayLayer.onDidChange(() => displayLayerDidChange = true); + buffer.undo(); + expect(updateCount).toBe(7); + }); + }); + + describe("::clear()", () => { + it("destroys all of the layer's markers", function(done) { + buffer = new TextBuffer({text: 'abc'}); + const displayLayer = buffer.addDisplayLayer(); + const markerLayer = buffer.addMarkerLayer(); + const displayMarkerLayer = displayLayer.getMarkerLayer(markerLayer.id); + const marker1 = markerLayer.markRange([[0, 1], [0, 2]]); + const marker2 = markerLayer.markRange([[0, 1], [0, 2]]); + const marker3 = markerLayer.markRange([[0, 1], [0, 2]]); + const displayMarker1 = displayMarkerLayer.getMarker(marker1.id); + // intentionally omit a display marker for marker2 just to cover that case + const displayMarker3 = displayMarkerLayer.getMarker(marker3.id); + + let marker1DestroyCount = 0; + let marker2DestroyCount = 0; + let displayMarker1DestroyCount = 0; + let displayMarker3DestroyCount = 0; + let markerLayerUpdateCount = 0; + let displayMarkerLayerUpdateCount = 0; + marker1.onDidDestroy(() => marker1DestroyCount++); + marker2.onDidDestroy(() => marker2DestroyCount++); + displayMarker1.onDidDestroy(() => displayMarker1DestroyCount++); + displayMarker3.onDidDestroy(() => displayMarker3DestroyCount++); + markerLayer.onDidUpdate(function() { + markerLayerUpdateCount++; + if ((markerLayerUpdateCount === 1) && (displayMarkerLayerUpdateCount === 1)) { done(); } + }); + displayMarkerLayer.onDidUpdate(function() { + displayMarkerLayerUpdateCount++; + if ((markerLayerUpdateCount === 1) && (displayMarkerLayerUpdateCount === 1)) { done(); } + }); + + markerLayer.clear(); + expect(marker1.isDestroyed()).toBe(true); + expect(marker2.isDestroyed()).toBe(true); + expect(marker3.isDestroyed()).toBe(true); + expect(displayMarker1.isDestroyed()).toBe(true); + expect(displayMarker3.isDestroyed()).toBe(true); + expect(marker1DestroyCount).toBe(1); + expect(marker2DestroyCount).toBe(1); + expect(displayMarker1DestroyCount).toBe(1); + expect(displayMarker3DestroyCount).toBe(1); + expect(markerLayer.getMarkers()).toEqual([]); + expect(displayMarkerLayer.getMarkers()).toEqual([]); + expect(displayMarkerLayer.getMarker(displayMarker3.id)).toBeUndefined(); + }); + }); + + describe("::copy", function() { + it("creates a new marker layer with markers in the same states", function() { + const originalLayer = buffer.addMarkerLayer({maintainHistory: true}); + originalLayer.markRange([[0, 1], [0, 3]]); + originalLayer.markPosition([0, 2]); + + const copy = originalLayer.copy(); + expect(copy).not.toBe(originalLayer); + + const markers = copy.getMarkers(); + expect(markers.length).toBe(2); + expect(markers[0].getRange()).toEqual([[0, 1], [0, 3]]); + expect(markers[1].getRange()).toEqual([[0, 2], [0, 2]]); + expect(markers[1].hasTail()).toBe(false); + }); + + it("copies the marker layer role", function() { + const originalLayer = buffer.addMarkerLayer({maintainHistory: true, role: "selections"}); + const copy = originalLayer.copy(); + expect(copy).not.toBe(originalLayer); + expect(copy.getRole()).toBe("selections"); + expect(buffer.selectionsMarkerLayerIds.has(originalLayer.id)).toBe(true); + expect(buffer.selectionsMarkerLayerIds.has(copy.id)).toBe(true); + expect(buffer.selectionsMarkerLayerIds.size).toBe(2); + }); + }); + + describe("::destroy", () => { + it("destroys the layer's markers", () => { + buffer = new TextBuffer(); + const markerLayer = buffer.addMarkerLayer(); + + const marker1 = markerLayer.markRange([[0, 0], [0, 0]]); + const marker2 = markerLayer.markRange([[0, 0], [0, 0]]); + + const destroyListener = jasmine.createSpy('onDidDestroy listener'); + marker1.onDidDestroy(destroyListener); + + markerLayer.destroy(); + + expect(destroyListener).toHaveBeenCalled(); + expect(marker1.isDestroyed()).toBe(true); + + // Markers states are updated regardless of whether they have an + // `::onDidDestroy` listener. + expect(marker2.isDestroyed()).toBe(true); + }) + }); + + describe("trackDestructionInOnDidCreateMarkerCallbacks", () => { + it("stores a stack trace when destroy is called during onDidCreateMarker callbacks", function() { + layer1.onDidCreateMarker(function(m) { if (destroyInCreateCallback) { return m.destroy(); } }); + + layer1.trackDestructionInOnDidCreateMarkerCallbacks = true; + var destroyInCreateCallback = true; + const marker1 = layer1.markPosition([0, 0]); + expect(marker1.isDestroyed()).toBe(true); + expect(marker1.destroyStackTrace).toBeDefined(); + + destroyInCreateCallback = false; + const marker2 = layer1.markPosition([0, 0]); + expect(marker2.isDestroyed()).toBe(false); + expect(marker2.destroyStackTrace).toBeUndefined(); + marker2.destroy(); + expect(marker2.isDestroyed()).toBe(true); + expect(marker2.destroyStackTrace).toBeUndefined(); + + destroyInCreateCallback = true; + layer1.trackDestructionInOnDidCreateMarkerCallbacks = false; + const marker3 = layer1.markPosition([0, 0]); + expect(marker3.isDestroyed()).toBe(true); + expect(marker3.destroyStackTrace).toBeUndefined(); + }); + }); +}); diff --git a/spec/marker-spec.coffee b/spec/marker-spec.coffee deleted file mode 100644 index a3ea821bca..0000000000 --- a/spec/marker-spec.coffee +++ /dev/null @@ -1,765 +0,0 @@ -{difference, times, uniq} = require 'underscore-plus' -TextBuffer = require '../src/text-buffer' - -describe "Marker", -> - [buffer, markerCreations, markersUpdatedCount] = [] - - beforeEach -> - jasmine.addCustomEqualityTester(require("underscore-plus").isEqual) - buffer = new TextBuffer(text: "abcdefghijklmnopqrstuvwxyz") - markerCreations = [] - buffer.onDidCreateMarker (marker) -> markerCreations.push(marker) - markersUpdatedCount = 0 - buffer.onDidUpdateMarkers -> markersUpdatedCount++ - - describe "creation", -> - describe "TextBuffer::markRange(range, properties)", -> - it "creates a marker for the given range with the given properties", -> - marker = buffer.markRange([[0, 3], [0, 6]]) - expect(marker.getRange()).toEqual [[0, 3], [0, 6]] - expect(marker.getHeadPosition()).toEqual [0, 6] - expect(marker.getTailPosition()).toEqual [0, 3] - expect(marker.isReversed()).toBe false - expect(marker.hasTail()).toBe true - expect(markerCreations).toEqual [marker] - expect(markersUpdatedCount).toBe 1 - - it "allows a reversed marker to be created", -> - marker = buffer.markRange([[0, 3], [0, 6]], reversed: true) - expect(marker.getRange()).toEqual [[0, 3], [0, 6]] - expect(marker.getHeadPosition()).toEqual [0, 3] - expect(marker.getTailPosition()).toEqual [0, 6] - expect(marker.isReversed()).toBe true - expect(marker.hasTail()).toBe true - - it "allows an invalidation strategy to be assigned", -> - marker = buffer.markRange([[0, 3], [0, 6]], invalidate: 'inside') - expect(marker.getInvalidationStrategy()).toBe 'inside' - - it "allows an exclusive marker to be created independently of its invalidation strategy", -> - layer = buffer.addMarkerLayer({maintainHistory: true}) - marker1 = layer.markRange([[0, 3], [0, 6]], invalidate: 'overlap', exclusive: true) - marker2 = marker1.copy() - marker3 = marker1.copy(exclusive: false) - marker4 = marker1.copy(exclusive: null, invalidate: 'inside') - - buffer.insert([0, 3], 'something') - - expect(marker1.getStartPosition()).toEqual [0, 12] - expect(marker1.isExclusive()).toBe true - expect(marker2.getStartPosition()).toEqual [0, 12] - expect(marker2.isExclusive()).toBe true - expect(marker3.getStartPosition()).toEqual [0, 3] - expect(marker3.isExclusive()).toBe false - expect(marker4.getStartPosition()).toEqual [0, 12] - expect(marker4.isExclusive()).toBe true - - it "allows custom state to be assigned", -> - marker = buffer.markRange([[0, 3], [0, 6]], foo: 1, bar: 2) - expect(marker.getProperties()).toEqual {foo: 1, bar: 2} - - it "clips the range before creating a marker with it", -> - marker = buffer.markRange([[-100, -100], [100, 100]]) - expect(marker.getRange()).toEqual [[0, 0], [0, 26]] - - it "throws an error if an invalid point is given", -> - marker1 = buffer.markRange([[0, 1], [0, 2]]) - - expect -> buffer.markRange([[0, NaN], [0, 2]]) - .toThrowError "Invalid Point: (0, NaN)" - expect -> buffer.markRange([[0, 1], [0, NaN]]) - .toThrowError "Invalid Point: (0, NaN)" - - expect(buffer.findMarkers({})).toEqual [marker1] - expect(buffer.getMarkers()).toEqual [marker1] - - it "allows arbitrary properties to be assigned", -> - marker = buffer.markRange([[0, 6], [0, 8]], foo: 'bar') - expect(marker.getProperties()).toEqual({foo: 'bar'}) - - describe "TextBuffer::markPosition(position, properties)", -> - it "creates a tail-less marker at the given position", -> - marker = buffer.markPosition([0, 6]) - expect(marker.getRange()).toEqual [[0, 6], [0, 6]] - expect(marker.getHeadPosition()).toEqual [0, 6] - expect(marker.getTailPosition()).toEqual [0, 6] - expect(marker.isReversed()).toBe false - expect(marker.hasTail()).toBe false - expect(markerCreations).toEqual [marker] - - it "allows an invalidation strategy to be assigned", -> - marker = buffer.markPosition([0, 3], invalidate: 'inside') - expect(marker.getInvalidationStrategy()).toBe 'inside' - - it "throws an error if an invalid point is given", -> - marker1 = buffer.markPosition([0, 1]) - - expect -> buffer.markPosition([0, NaN]) - .toThrowError "Invalid Point: (0, NaN)" - - expect(buffer.findMarkers({})).toEqual [marker1] - expect(buffer.getMarkers()).toEqual [marker1] - - it "allows arbitrary properties to be assigned", -> - marker = buffer.markPosition([0, 6], foo: 'bar') - expect(marker.getProperties()).toEqual({foo: 'bar'}) - - describe "direct updates", -> - [marker, changes] = [] - - beforeEach -> - marker = buffer.markRange([[0, 6], [0, 9]]) - changes = [] - markersUpdatedCount = 0 - marker.onDidChange (change) -> changes.push(change) - - describe "::setHeadPosition(position, state)", -> - it "sets the head position of the marker, flipping its orientation if necessary", -> - marker.setHeadPosition([0, 12]) - expect(marker.getRange()).toEqual [[0, 6], [0, 12]] - expect(marker.isReversed()).toBe false - expect(markersUpdatedCount).toBe 1 - expect(changes).toEqual [{ - oldHeadPosition: [0, 9], newHeadPosition: [0, 12] - oldTailPosition: [0, 6], newTailPosition: [0, 6] - hadTail: true, hasTail: true - wasValid: true, isValid: true - oldProperties: {}, newProperties: {} - textChanged: false - }] - - changes = [] - marker.setHeadPosition([0, 3]) - expect(markersUpdatedCount).toBe 2 - expect(marker.getRange()).toEqual [[0, 3], [0, 6]] - expect(marker.isReversed()).toBe true - expect(changes).toEqual [{ - oldHeadPosition: [0, 12], newHeadPosition: [0, 3] - oldTailPosition: [0, 6], newTailPosition: [0, 6] - hadTail: true, hasTail: true - wasValid: true, isValid: true - oldProperties: {}, newProperties: {} - textChanged: false - }] - - changes = [] - marker.setHeadPosition([0, 9]) - expect(markersUpdatedCount).toBe 3 - expect(marker.getRange()).toEqual [[0, 6], [0, 9]] - expect(marker.isReversed()).toBe false - expect(changes).toEqual [{ - oldHeadPosition: [0, 3], newHeadPosition: [0, 9] - oldTailPosition: [0, 6], newTailPosition: [0, 6] - hadTail: true, hasTail: true - wasValid: true, isValid: true - oldProperties: {}, newProperties: {} - textChanged: false - }] - - it "does not give the marker a tail if it doesn't have one already", -> - marker.clearTail() - expect(marker.hasTail()).toBe false - marker.setHeadPosition([0, 15]) - expect(marker.hasTail()).toBe false - expect(marker.getRange()).toEqual [[0, 15], [0, 15]] - - it "does not notify ::onDidChange observers and returns false if the position isn't actually changed", -> - expect(marker.setHeadPosition(marker.getHeadPosition())).toBe false - expect(markersUpdatedCount).toBe 0 - expect(changes.length).toBe 0 - - it "clips the assigned position", -> - marker.setHeadPosition([100, 100]) - expect(marker.getHeadPosition()).toEqual [0, 26] - - describe "::setTailPosition(position, state)", -> - it "sets the head position of the marker, flipping its orientation if necessary", -> - marker.setTailPosition([0, 3]) - expect(marker.getRange()).toEqual [[0, 3], [0, 9]] - expect(marker.isReversed()).toBe false - expect(changes).toEqual [{ - oldHeadPosition: [0, 9], newHeadPosition: [0, 9] - oldTailPosition: [0, 6], newTailPosition: [0, 3] - hadTail: true, hasTail: true - wasValid: true, isValid: true - oldProperties: {}, newProperties: {} - textChanged: false - }] - - changes = [] - marker.setTailPosition([0, 12]) - expect(marker.getRange()).toEqual [[0, 9], [0, 12]] - expect(marker.isReversed()).toBe true - expect(changes).toEqual [{ - oldHeadPosition: [0, 9], newHeadPosition: [0, 9] - oldTailPosition: [0, 3], newTailPosition: [0, 12] - hadTail: true, hasTail: true - wasValid: true, isValid: true - oldProperties: {}, newProperties: {} - textChanged: false - }] - - changes = [] - marker.setTailPosition([0, 6]) - expect(marker.getRange()).toEqual [[0, 6], [0, 9]] - expect(marker.isReversed()).toBe false - expect(changes).toEqual [{ - oldHeadPosition: [0, 9], newHeadPosition: [0, 9] - oldTailPosition: [0, 12], newTailPosition: [0, 6] - hadTail: true, hasTail: true - wasValid: true, isValid: true - oldProperties: {}, newProperties: {} - textChanged: false - }] - - it "plants the tail of the marker if it does not have a tail", -> - marker.clearTail() - expect(marker.hasTail()).toBe false - marker.setTailPosition([0, 0]) - expect(marker.hasTail()).toBe true - expect(marker.getRange()).toEqual [[0, 0], [0, 9]] - - it "does not notify ::onDidChange observers and returns false if the position isn't actually changed", -> - expect(marker.setTailPosition(marker.getTailPosition())).toBe false - expect(changes.length).toBe 0 - - it "clips the assigned position", -> - marker.setTailPosition([100, 100]) - expect(marker.getTailPosition()).toEqual [0, 26] - - describe "::setRange(range, options)", -> - it "sets the head and tail position simultaneously, flipping the orientation if the 'isReversed' option is true", -> - marker.setRange([[0, 8], [0, 12]]) - expect(marker.getRange()).toEqual [[0, 8], [0, 12]] - expect(marker.isReversed()).toBe false - expect(marker.getHeadPosition()).toEqual [0, 12] - expect(marker.getTailPosition()).toEqual [0, 8] - expect(changes).toEqual [{ - oldHeadPosition: [0, 9], newHeadPosition: [0, 12] - oldTailPosition: [0, 6], newTailPosition: [0, 8] - hadTail: true, hasTail: true - wasValid: true, isValid: true - oldProperties: {}, newProperties: {} - textChanged: false - }] - - changes = [] - marker.setRange([[0, 3], [0, 9]], reversed: true) - expect(marker.getRange()).toEqual [[0, 3], [0, 9]] - expect(marker.isReversed()).toBe true - expect(marker.getHeadPosition()).toEqual [0, 3] - expect(marker.getTailPosition()).toEqual [0, 9] - expect(changes).toEqual [{ - oldHeadPosition: [0, 12], newHeadPosition: [0, 3] - oldTailPosition: [0, 8], newTailPosition: [0, 9] - hadTail: true, hasTail: true - wasValid: true, isValid: true - oldProperties: {}, newProperties: {} - textChanged: false - }] - - it "plants the tail of the marker if it does not have a tail", -> - marker.clearTail() - expect(marker.hasTail()).toBe false - marker.setRange([[0, 1], [0, 10]]) - expect(marker.hasTail()).toBe true - expect(marker.getRange()).toEqual [[0, 1], [0, 10]] - - it "clips the assigned range", -> - marker.setRange([[-100, -100], [100, 100]]) - expect(marker.getRange()).toEqual [[0, 0], [0, 26]] - - it "emits the right events when called inside of an ::onDidChange handler", -> - marker.onDidChange (change) -> - if marker.getHeadPosition().isEqual([0, 5]) - marker.setHeadPosition([0, 6]) - - marker.setHeadPosition([0, 5]) - - headPositions = for {oldHeadPosition, newHeadPosition} in changes - {old: oldHeadPosition, new: newHeadPosition} - - expect(headPositions).toEqual [ - {old: [0, 9], new: [0, 5]} - {old: [0, 5], new: [0, 6]} - ] - - it "throws an error if an invalid range is given", -> - expect -> marker.setRange([[0, NaN], [0, 12]]) - .toThrowError "Invalid Point: (0, NaN)" - - expect(buffer.findMarkers({})).toEqual [marker] - expect(marker.getRange()).toEqual [[0, 6], [0, 9]] - - describe "::clearTail() / ::plantTail()", -> - it "clears the tail / plants the tail at the current head position", -> - marker.setRange([[0, 6], [0, 9]], reversed: true) - - changes = [] - marker.clearTail() - expect(marker.getRange()).toEqual [[0, 6], [0, 6]] - expect(marker.hasTail()).toBe false - expect(marker.isReversed()).toBe false - - expect(changes).toEqual [{ - oldHeadPosition: [0, 6], newHeadPosition: [0, 6] - oldTailPosition: [0, 9], newTailPosition: [0, 6] - hadTail: true, hasTail: false - wasValid: true, isValid: true - oldProperties: {}, newProperties: {} - textChanged: false - }] - - changes = [] - marker.setHeadPosition([0, 12]) - expect(marker.getRange()).toEqual [[0, 12], [0, 12]] - expect(changes).toEqual [{ - oldHeadPosition: [0, 6], newHeadPosition: [0, 12] - oldTailPosition: [0, 6], newTailPosition: [0, 12] - hadTail: false, hasTail: false - wasValid: true, isValid: true - oldProperties: {}, newProperties: {} - textChanged: false - }] - - changes = [] - marker.plantTail() - expect(marker.hasTail()).toBe true - expect(marker.isReversed()).toBe false - expect(marker.getRange()).toEqual [[0, 12], [0, 12]] - expect(changes).toEqual [{ - oldHeadPosition: [0, 12], newHeadPosition: [0, 12] - oldTailPosition: [0, 12], newTailPosition: [0, 12] - hadTail: false, hasTail: true - wasValid: true, isValid: true - oldProperties: {}, newProperties: {} - textChanged: false - }] - - changes = [] - marker.setHeadPosition([0, 15]) - expect(marker.getRange()).toEqual [[0, 12], [0, 15]] - expect(changes).toEqual [{ - oldHeadPosition: [0, 12], newHeadPosition: [0, 15] - oldTailPosition: [0, 12], newTailPosition: [0, 12] - hadTail: true, hasTail: true - wasValid: true, isValid: true - oldProperties: {}, newProperties: {} - textChanged: false - }] - - changes = [] - marker.plantTail() - expect(marker.getRange()).toEqual [[0, 12], [0, 15]] - expect(changes).toEqual [] - - describe "::setProperties(properties)", -> - it "merges the given properties into the current properties", -> - marker.setProperties(foo: 1) - expect(marker.getProperties()).toEqual {foo: 1} - marker.setProperties(bar: 2) - expect(marker.getProperties()).toEqual {foo: 1, bar: 2} - expect(markersUpdatedCount).toBe 2 - - describe "indirect updates (due to buffer changes)", -> - [allStrategies, neverMarker, surroundMarker, overlapMarker, insideMarker, touchMarker] = [] - - beforeEach -> - overlapMarker = buffer.markRange([[0, 6], [0, 9]], invalidate: 'overlap') - neverMarker = overlapMarker.copy(invalidate: 'never') - surroundMarker = overlapMarker.copy(invalidate: 'surround') - insideMarker = overlapMarker.copy(invalidate: 'inside') - touchMarker = overlapMarker.copy(invalidate: 'touch') - allStrategies = [neverMarker, surroundMarker, overlapMarker, insideMarker, touchMarker] - markersUpdatedCount = 0 - - it "defers notifying Marker::onDidChange observers until after notifying Buffer::onDidChange observers", -> - for marker in allStrategies - do (marker) -> - marker.changes = [] - marker.onDidChange (change) -> - marker.changes.push(change) - - changedCount = 0 - changeSubscription = - buffer.onDidChange (change) -> - changedCount++ - expect(markersUpdatedCount).toBe 0 - for marker in allStrategies - expect(marker.getRange()).toEqual [[0, 8], [0, 11]] - expect(marker.isValid()).toBe true - expect(marker.changes.length).toBe 0 - - buffer.setTextInRange([[0, 1], [0, 2]], "ABC") - - expect(changedCount).toBe 1 - - for marker in allStrategies - expect(marker.changes).toEqual [{ - oldHeadPosition: [0, 9], newHeadPosition: [0, 11] - oldTailPosition: [0, 6], newTailPosition: [0, 8] - hadTail: true, hasTail: true - wasValid: true, isValid: true - oldProperties: {}, newProperties: {} - textChanged: true - }] - expect(markersUpdatedCount).toBe 1 - - marker.changes = [] for marker in allStrategies - changeSubscription.dispose() - changedCount = 0 - markersUpdatedCount = 0 - buffer.onDidChange (change) -> - changedCount++ - expect(markersUpdatedCount).toBe 0 - for marker in allStrategies - expect(marker.getRange()).toEqual [[0, 6], [0, 9]] - expect(marker.isValid()).toBe true - expect(marker.changes.length).toBe 0 - - it "notifies ::onDidUpdateMarkers observers even if there are no Marker::onDidChange observers", -> - expect(markersUpdatedCount).toBe 0 - buffer.insert([0, 0], "123") - expect(markersUpdatedCount).toBe 1 - overlapMarker.setRange([[0, 1], [0, 2]]) - expect(markersUpdatedCount).toBe 2 - - it "emits onDidChange events when undoing/redoing text changes that move the marker", -> - marker = buffer.markRange([[0, 4], [0, 8]]) - buffer.insert([0, 0], 'ABCD') - - changes = [] - marker.onDidChange (change) -> changes.push(change) - buffer.undo() - expect(changes.length).toBe 1 - expect(changes[0].newHeadPosition).toEqual [0, 8] - buffer.redo() - expect(changes.length).toBe 2 - expect(changes[1].newHeadPosition).toEqual [0, 12] - - describe "when a change precedes a marker", -> - it "shifts the marker based on the characters inserted or removed by the change", -> - buffer.setTextInRange([[0, 1], [0, 2]], "ABC") - for marker in allStrategies - expect(marker.getRange()).toEqual [[0, 8], [0, 11]] - expect(marker.isValid()).toBe true - - buffer.setTextInRange([[0, 1], [0, 1]], '\nDEF') - for marker in allStrategies - expect(marker.getRange()).toEqual [[1, 10], [1, 13]] - expect(marker.isValid()).toBe true - - describe "when a change follows a marker", -> - it "does not shift the marker", -> - buffer.setTextInRange([[0, 10], [0, 12]], "ABC") - for marker in allStrategies - expect(marker.getRange()).toEqual [[0, 6], [0, 9]] - expect(marker.isValid()).toBe true - - describe "when a change starts at a marker's start position", -> - describe "when the marker has a tail", -> - it "interprets the change as being inside the marker for all invalidation strategies", -> - buffer.setTextInRange([[0, 6], [0, 7]], "ABC") - - for marker in difference(allStrategies, [insideMarker, touchMarker]) - expect(marker.getRange()).toEqual [[0, 6], [0, 11]] - expect(marker.isValid()).toBe true - - expect(insideMarker.getRange()).toEqual [[0, 9], [0, 11]] - expect(insideMarker.isValid()).toBe false - expect(touchMarker.getRange()).toEqual [[0, 6], [0, 11]] - expect(touchMarker.isValid()).toBe false - - describe "when the marker has no tail", -> - it "interprets the change as being outside the marker for all invalidation strategies", -> - for marker in allStrategies - marker.setRange([[0, 6], [0, 11]], reversed: true) - marker.clearTail() - expect(marker.getRange()).toEqual [[0, 6], [0, 6]] - - buffer.setTextInRange([[0, 6], [0, 6]], "ABC") - - for marker in difference(allStrategies, [touchMarker]) - expect(marker.getRange()).toEqual [[0, 9], [0, 9]] - expect(marker.isValid()).toBe true - - expect(touchMarker.getRange()).toEqual [[0, 9], [0, 9]] - expect(touchMarker.isValid()).toBe false - - buffer.setTextInRange([[0, 9], [0, 9]], "DEF") - - for marker in difference(allStrategies, [touchMarker]) - expect(marker.getRange()).toEqual [[0, 12], [0, 12]] - expect(marker.isValid()).toBe true - - expect(touchMarker.getRange()).toEqual [[0, 12], [0, 12]] - expect(touchMarker.isValid()).toBe false - - describe "when a change ends at a marker's start position but starts before it", -> - it "interprets the change as being outside the marker for all invalidation strategies", -> - buffer.setTextInRange([[0, 4], [0, 6]], "ABC") - - for marker in difference(allStrategies, [touchMarker]) - expect(marker.getRange()).toEqual [[0, 7], [0, 10]] - expect(marker.isValid()).toBe true - - expect(touchMarker.getRange()).toEqual [[0, 7], [0, 10]] - expect(touchMarker.isValid()).toBe false - - describe "when a change starts and ends at a marker's start position", -> - it "interprets the change as being inside the marker for all invalidation strategies except 'inside'", -> - buffer.insert([0, 6], "ABC") - - for marker in difference(allStrategies, [insideMarker, touchMarker]) - expect(marker.getRange()).toEqual [[0, 6], [0, 12]] - expect(marker.isValid()).toBe true - - expect(insideMarker.getRange()).toEqual [[0, 9], [0, 12]] - expect(insideMarker.isValid()).toBe true - - expect(touchMarker.getRange()).toEqual [[0, 6], [0, 12]] - expect(touchMarker.isValid()).toBe false - - describe "when a change starts at a marker's end position", -> - describe "when the change is an insertion", -> - it "interprets the change as being inside the marker for all invalidation strategies except 'inside'", -> - buffer.setTextInRange([[0, 9], [0, 9]], "ABC") - - for marker in difference(allStrategies, [insideMarker, touchMarker]) - expect(marker.getRange()).toEqual [[0, 6], [0, 12]] - expect(marker.isValid()).toBe true - - expect(insideMarker.getRange()).toEqual [[0, 6], [0, 9]] - expect(insideMarker.isValid()).toBe true - - expect(touchMarker.getRange()).toEqual [[0, 6], [0, 12]] - expect(touchMarker.isValid()).toBe false - - describe "when the change replaces some existing text", -> - it "interprets the change as being outside the marker for all invalidation strategies", -> - buffer.setTextInRange([[0, 9], [0, 11]], "ABC") - - for marker in difference(allStrategies, [touchMarker]) - expect(marker.getRange()).toEqual [[0, 6], [0, 9]] - expect(marker.isValid()).toBe true - - expect(touchMarker.getRange()).toEqual [[0, 6], [0, 9]] - expect(touchMarker.isValid()).toBe false - - describe "when a change surrounds a marker", -> - it "truncates the marker to the end of the change and invalidates every invalidation strategy except 'never'", -> - buffer.setTextInRange([[0, 5], [0, 10]], "ABC") - - for marker in allStrategies - expect(marker.getRange()).toEqual [[0, 8], [0, 8]] - - for marker in difference(allStrategies, [neverMarker]) - expect(marker.isValid()).toBe false - - expect(neverMarker.isValid()).toBe true - - describe "when a change is inside a marker", -> - it "adjusts the marker's end position and invalidates markers with an 'inside' or 'touch' strategy", -> - buffer.setTextInRange([[0, 7], [0, 8]], "AB") - - for marker in allStrategies - expect(marker.getRange()).toEqual [[0, 6], [0, 10]] - - for marker in difference(allStrategies, [insideMarker, touchMarker]) - expect(marker.isValid()).toBe true - - expect(insideMarker.isValid()).toBe false - expect(touchMarker.isValid()).toBe false - - describe "when a change overlaps the start of a marker", -> - it "moves the start of the marker to the end of the change and invalidates the marker if its stategy is 'overlap', 'inside', or 'touch'", -> - buffer.setTextInRange([[0, 5], [0, 7]], "ABC") - - for marker in allStrategies - expect(marker.getRange()).toEqual [[0, 8], [0, 10]] - - expect(neverMarker.isValid()).toBe true - expect(surroundMarker.isValid()).toBe true - expect(overlapMarker.isValid()).toBe false - expect(insideMarker.isValid()).toBe false - expect(touchMarker.isValid()).toBe false - - describe "when a change overlaps the end of a marker", -> - it "moves the end of the marker to the end of the change and invalidates the marker if its stategy is 'overlap', 'inside', or 'touch'", -> - buffer.setTextInRange([[0, 8], [0, 10]], "ABC") - - for marker in allStrategies - expect(marker.getRange()).toEqual [[0, 6], [0, 11]] - - expect(neverMarker.isValid()).toBe true - expect(surroundMarker.isValid()).toBe true - expect(overlapMarker.isValid()).toBe false - expect(insideMarker.isValid()).toBe false - expect(touchMarker.isValid()).toBe false - - describe "when multiple changes occur in a transaction", -> - it "emits one change event for each marker that was indirectly updated", -> - for marker in allStrategies - do (marker) -> - marker.changes = [] - marker.onDidChange (change) -> - marker.changes.push(change) - - buffer.transact -> - buffer.insert([0, 7], ".") - buffer.append("!") - - for marker in allStrategies - expect(marker.changes.length).toBe 0 - - neverMarker.setRange([[0, 0], [0, 1]]) - - expect(neverMarker.changes).toEqual [{ - oldHeadPosition: [0, 9] - newHeadPosition: [0, 1] - oldTailPosition: [0, 6] - newTailPosition: [0, 0] - wasValid: true - isValid: true - hadTail: true - hasTail: true - oldProperties: {} - newProperties: {} - textChanged: false - }] - - expect(insideMarker.changes).toEqual [{ - oldHeadPosition: [0, 9] - newHeadPosition: [0, 10] - oldTailPosition: [0, 6] - newTailPosition: [0, 6] - wasValid: true - isValid: false - hadTail: true - hasTail: true - oldProperties: {} - newProperties: {} - textChanged: true - }] - - describe "destruction", -> - it "removes the marker from the buffer, marks it destroyed and invalid, and notifies ::onDidDestroy observers", -> - marker = buffer.markRange([[0, 3], [0, 6]]) - expect(buffer.getMarker(marker.id)).toBe marker - marker.onDidDestroy destroyedHandler = jasmine.createSpy("destroyedHandler") - - marker.destroy() - - expect(destroyedHandler.calls.count()).toBe 1 - expect(buffer.getMarker(marker.id)).toBeUndefined() - expect(marker.isDestroyed()).toBe true - expect(marker.isValid()).toBe false - expect(marker.getRange()).toEqual [[0, 0], [0, 0]] - - it "handles markers deleted in event handlers", -> - marker1 = buffer.markRange([[0, 3], [0, 6]]) - marker2 = marker1.copy() - marker3 = marker1.copy() - - marker1.onDidChange -> - marker1.destroy() - marker2.destroy() - marker3.destroy() - - # doesn't blow up. - buffer.insert([0, 0], "!") - - marker1 = buffer.markRange([[0, 3], [0, 6]]) - marker2 = marker1.copy() - marker3 = marker1.copy() - - marker1.onDidChange -> - marker1.destroy() - marker2.destroy() - marker3.destroy() - - # doesn't blow up. - buffer.undo() - - it "does not reinsert the marker if its range is later updated", -> - marker = buffer.markRange([[0, 3], [0, 6]]) - marker.destroy() - expect(buffer.findMarkers(intersectsRow: 0)).toEqual [] - marker.setRange([[0, 0], [0, 9]]) - expect(buffer.findMarkers(intersectsRow: 0)).toEqual [] - - it "does not blow up when destroy is called twice", -> - marker = buffer.markRange([[0, 3], [0, 6]]) - marker.destroy() - marker.destroy() - - describe "TextBuffer::findMarkers(properties)", -> - [marker1, marker2, marker3, marker4] = [] - - beforeEach -> - marker1 = buffer.markRange([[0, 0], [0, 3]], class: 'a') - marker2 = buffer.markRange([[0, 0], [0, 5]], class: 'a', invalidate: 'surround') - marker3 = buffer.markRange([[0, 4], [0, 7]], class: 'a') - marker4 = buffer.markRange([[0, 0], [0, 7]], class: 'b', invalidate: 'never') - - it "can find markers based on custom properties", -> - expect(buffer.findMarkers(class: 'a')).toEqual [marker2, marker1, marker3] - expect(buffer.findMarkers(class: 'b')).toEqual [marker4] - - it "can find markers based on their invalidation strategy", -> - expect(buffer.findMarkers(invalidate: 'overlap')).toEqual [marker1, marker3] - expect(buffer.findMarkers(invalidate: 'surround')).toEqual [marker2] - expect(buffer.findMarkers(invalidate: 'never')).toEqual [marker4] - - it "can find markers that start or end at a given position", -> - expect(buffer.findMarkers(startPosition: [0, 0])).toEqual [marker4, marker2, marker1] - expect(buffer.findMarkers(startPosition: [0, 0], class: 'a')).toEqual [marker2, marker1] - expect(buffer.findMarkers(startPosition: [0, 0], endPosition: [0, 3], class: 'a')).toEqual [marker1] - expect(buffer.findMarkers(startPosition: [0, 4], endPosition: [0, 7])).toEqual [marker3] - expect(buffer.findMarkers(endPosition: [0, 7])).toEqual [marker4, marker3] - expect(buffer.findMarkers(endPosition: [0, 7], class: 'b')).toEqual [marker4] - - it "can find markers that start or end at a given range", -> - expect(buffer.findMarkers(startsInRange: [[0, 0], [0, 4]])).toEqual [marker4, marker2, marker1, marker3] - expect(buffer.findMarkers(startsInRange: [[0, 0], [0, 4]], class: 'a')).toEqual [marker2, marker1, marker3] - expect(buffer.findMarkers(startsInRange: [[0, 0], [0, 4]], endsInRange: [[0, 3], [0, 6]])).toEqual [marker2, marker1] - expect(buffer.findMarkers(endsInRange: [[0, 5], [0, 7]])).toEqual [marker4, marker2, marker3] - - it "can find markers that contain a given point", -> - expect(buffer.findMarkers(containsPosition: [0, 0])).toEqual [marker4, marker2, marker1] - expect(buffer.findMarkers(containsPoint: [0, 0])).toEqual [marker4, marker2, marker1] - expect(buffer.findMarkers(containsPoint: [0, 1], class: 'a')).toEqual [marker2, marker1] - expect(buffer.findMarkers(containsPoint: [0, 4])).toEqual [marker4, marker2, marker3] - - it "can find markers that contain a given range", -> - expect(buffer.findMarkers(containsRange: [[0, 1], [0, 4]])).toEqual [marker4, marker2] - expect(buffer.findMarkers(containsRange: [[0, 4], [0, 1]])).toEqual [marker4, marker2] - expect(buffer.findMarkers(containsRange: [[0, 1], [0, 3]])).toEqual [marker4, marker2, marker1] - expect(buffer.findMarkers(containsRange: [[0, 6], [0, 7]])).toEqual [marker4, marker3] - - it "can find markers that intersect a given range", -> - expect(buffer.findMarkers(intersectsRange: [[0, 4], [0, 6]])).toEqual [marker4, marker2, marker3] - expect(buffer.findMarkers(intersectsRange: [[0, 0], [0, 2]])).toEqual [marker4, marker2, marker1] - - it "can find markers that start or end at a given row", -> - buffer.setTextInRange([[0, 7], [0, 7]], '\n') - buffer.setTextInRange([[0, 3], [0, 4]], ' \n') - expect(buffer.findMarkers(startRow: 0)).toEqual [marker4, marker2, marker1] - expect(buffer.findMarkers(startRow: 1)).toEqual [marker3] - expect(buffer.findMarkers(endRow: 2)).toEqual [marker4, marker3] - expect(buffer.findMarkers(startRow: 0, endRow: 2)).toEqual [marker4] - - it "can find markers that intersect a given row", -> - buffer.setTextInRange([[0, 7], [0, 7]], '\n') - buffer.setTextInRange([[0, 3], [0, 4]], ' \n') - expect(buffer.findMarkers(intersectsRow: 0)).toEqual [marker4, marker2, marker1] - expect(buffer.findMarkers(intersectsRow: 1)).toEqual [marker4, marker2, marker3] - - it "can find markers that intersect a given range", -> - buffer.setTextInRange([[0, 7], [0, 7]], '\n') - buffer.setTextInRange([[0, 3], [0, 4]], ' \n') - expect(buffer.findMarkers(intersectsRowRange: [1, 2])).toEqual [marker4, marker2, marker3] - - it "can find markers that are contained within a certain range, inclusive", -> - expect(buffer.findMarkers(containedInRange: [[0, 0], [0, 6]])).toEqual [marker2, marker1] - expect(buffer.findMarkers(containedInRange: [[0, 4], [0, 7]])).toEqual [marker3] diff --git a/spec/marker-spec.js b/spec/marker-spec.js new file mode 100644 index 0000000000..7e5c29ba34 --- /dev/null +++ b/spec/marker-spec.js @@ -0,0 +1,890 @@ +const {difference, times, uniq} = require('underscore-plus'); +const TextBuffer = require('../src/text-buffer'); + +describe("Marker", function() { + let buffer, markerCreations, markersUpdatedCount; + + beforeEach(function() { + jasmine.addCustomEqualityTester(require("underscore-plus").isEqual); + buffer = new TextBuffer({text: "abcdefghijklmnopqrstuvwxyz"}); + markerCreations = []; + buffer.onDidCreateMarker(marker => markerCreations.push(marker)); + markersUpdatedCount = 0; + buffer.onDidUpdateMarkers(() => markersUpdatedCount++); + }); + + describe("creation", function() { + describe("TextBuffer::markRange(range, properties)", function() { + it("creates a marker for the given range with the given properties", function() { + const marker = buffer.markRange([[0, 3], [0, 6]]); + expect(marker.getRange()).toEqual([[0, 3], [0, 6]]); + expect(marker.getHeadPosition()).toEqual([0, 6]); + expect(marker.getTailPosition()).toEqual([0, 3]); + expect(marker.isReversed()).toBe(false); + expect(marker.hasTail()).toBe(true); + expect(markerCreations).toEqual([marker]); + expect(markersUpdatedCount).toBe(1); + }); + + it("allows a reversed marker to be created", function() { + const marker = buffer.markRange([[0, 3], [0, 6]], {reversed: true}); + expect(marker.getRange()).toEqual([[0, 3], [0, 6]]); + expect(marker.getHeadPosition()).toEqual([0, 3]); + expect(marker.getTailPosition()).toEqual([0, 6]); + expect(marker.isReversed()).toBe(true); + expect(marker.hasTail()).toBe(true); + }); + + it("allows an invalidation strategy to be assigned", function() { + const marker = buffer.markRange([[0, 3], [0, 6]], {invalidate: 'inside'}); + expect(marker.getInvalidationStrategy()).toBe('inside'); + }); + + it("allows an exclusive marker to be created independently of its invalidation strategy", function() { + const layer = buffer.addMarkerLayer({maintainHistory: true}); + const marker1 = layer.markRange([[0, 3], [0, 6]], {invalidate: 'overlap', exclusive: true}); + const marker2 = marker1.copy(); + const marker3 = marker1.copy({exclusive: false}); + const marker4 = marker1.copy({exclusive: null, invalidate: 'inside'}); + + buffer.insert([0, 3], 'something'); + + expect(marker1.getStartPosition()).toEqual([0, 12]); + expect(marker1.isExclusive()).toBe(true); + expect(marker2.getStartPosition()).toEqual([0, 12]); + expect(marker2.isExclusive()).toBe(true); + expect(marker3.getStartPosition()).toEqual([0, 3]); + expect(marker3.isExclusive()).toBe(false); + expect(marker4.getStartPosition()).toEqual([0, 12]); + expect(marker4.isExclusive()).toBe(true); + }); + + it("allows custom state to be assigned", function() { + const marker = buffer.markRange([[0, 3], [0, 6]], {foo: 1, bar: 2}); + expect(marker.getProperties()).toEqual({foo: 1, bar: 2}); + }); + + it("clips the range before creating a marker with it", function() { + const marker = buffer.markRange([[-100, -100], [100, 100]]); + expect(marker.getRange()).toEqual([[0, 0], [0, 26]]); + }); + + it("throws an error if an invalid point is given", function() { + const marker1 = buffer.markRange([[0, 1], [0, 2]]); + + expect(() => buffer.markRange([[0, NaN], [0, 2]])) + .toThrowError("Invalid Point: (0, NaN)"); + expect(() => buffer.markRange([[0, 1], [0, NaN]])) + .toThrowError("Invalid Point: (0, NaN)"); + + expect(buffer.findMarkers({})).toEqual([marker1]); + expect(buffer.getMarkers()).toEqual([marker1]); + }); + + it("allows arbitrary properties to be assigned", function() { + const marker = buffer.markRange([[0, 6], [0, 8]], {foo: 'bar'}); + expect(marker.getProperties()).toEqual({foo: 'bar'}); + }); + }); + + describe("TextBuffer::markPosition(position, properties)", function() { + it("creates a tail-less marker at the given position", function() { + const marker = buffer.markPosition([0, 6]); + expect(marker.getRange()).toEqual([[0, 6], [0, 6]]); + expect(marker.getHeadPosition()).toEqual([0, 6]); + expect(marker.getTailPosition()).toEqual([0, 6]); + expect(marker.isReversed()).toBe(false); + expect(marker.hasTail()).toBe(false); + expect(markerCreations).toEqual([marker]); + }); + + it("allows an invalidation strategy to be assigned", function() { + const marker = buffer.markPosition([0, 3], {invalidate: 'inside'}); + expect(marker.getInvalidationStrategy()).toBe('inside'); + }); + + it("throws an error if an invalid point is given", function() { + const marker1 = buffer.markPosition([0, 1]); + + expect(() => buffer.markPosition([0, NaN])) + .toThrowError("Invalid Point: (0, NaN)"); + + expect(buffer.findMarkers({})).toEqual([marker1]); + expect(buffer.getMarkers()).toEqual([marker1]); + }); + + it("allows arbitrary properties to be assigned", function() { + const marker = buffer.markPosition([0, 6], {foo: 'bar'}); + expect(marker.getProperties()).toEqual({foo: 'bar'}); + }); + }); + }); + + describe("direct updates", function() { + let marker, changes; + + beforeEach(function() { + marker = buffer.markRange([[0, 6], [0, 9]]); + changes = []; + markersUpdatedCount = 0; + marker.onDidChange(change => changes.push(change)); + }); + + describe("::setHeadPosition(position, state)", function() { + it("sets the head position of the marker, flipping its orientation if necessary", function() { + marker.setHeadPosition([0, 12]); + expect(marker.getRange()).toEqual([[0, 6], [0, 12]]); + expect(marker.isReversed()).toBe(false); + expect(markersUpdatedCount).toBe(1); + expect(changes).toEqual([{ + oldHeadPosition: [0, 9], newHeadPosition: [0, 12], + oldTailPosition: [0, 6], newTailPosition: [0, 6], + hadTail: true, hasTail: true, + wasValid: true, isValid: true, + oldProperties: {}, newProperties: {}, + textChanged: false + }]); + + changes = []; + marker.setHeadPosition([0, 3]); + expect(markersUpdatedCount).toBe(2); + expect(marker.getRange()).toEqual([[0, 3], [0, 6]]); + expect(marker.isReversed()).toBe(true); + expect(changes).toEqual([{ + oldHeadPosition: [0, 12], newHeadPosition: [0, 3], + oldTailPosition: [0, 6], newTailPosition: [0, 6], + hadTail: true, hasTail: true, + wasValid: true, isValid: true, + oldProperties: {}, newProperties: {}, + textChanged: false + }]); + + changes = []; + marker.setHeadPosition([0, 9]); + expect(markersUpdatedCount).toBe(3); + expect(marker.getRange()).toEqual([[0, 6], [0, 9]]); + expect(marker.isReversed()).toBe(false); + expect(changes).toEqual([{ + oldHeadPosition: [0, 3], newHeadPosition: [0, 9], + oldTailPosition: [0, 6], newTailPosition: [0, 6], + hadTail: true, hasTail: true, + wasValid: true, isValid: true, + oldProperties: {}, newProperties: {}, + textChanged: false + }]); + }); + + it("does not give the marker a tail if it doesn't have one already", function() { + marker.clearTail(); + expect(marker.hasTail()).toBe(false); + marker.setHeadPosition([0, 15]); + expect(marker.hasTail()).toBe(false); + expect(marker.getRange()).toEqual([[0, 15], [0, 15]]); + }); + + it("does not notify ::onDidChange observers and returns false if the position isn't actually changed", function() { + expect(marker.setHeadPosition(marker.getHeadPosition())).toBe(false); + expect(markersUpdatedCount).toBe(0); + expect(changes.length).toBe(0); + }); + + it("clips the assigned position", function() { + marker.setHeadPosition([100, 100]); + expect(marker.getHeadPosition()).toEqual([0, 26]); + }); + }); + + describe("::setTailPosition(position, state)", function() { + it("sets the head position of the marker, flipping its orientation if necessary", function() { + marker.setTailPosition([0, 3]); + expect(marker.getRange()).toEqual([[0, 3], [0, 9]]); + expect(marker.isReversed()).toBe(false); + expect(changes).toEqual([{ + oldHeadPosition: [0, 9], newHeadPosition: [0, 9], + oldTailPosition: [0, 6], newTailPosition: [0, 3], + hadTail: true, hasTail: true, + wasValid: true, isValid: true, + oldProperties: {}, newProperties: {}, + textChanged: false + }]); + + changes = []; + marker.setTailPosition([0, 12]); + expect(marker.getRange()).toEqual([[0, 9], [0, 12]]); + expect(marker.isReversed()).toBe(true); + expect(changes).toEqual([{ + oldHeadPosition: [0, 9], newHeadPosition: [0, 9], + oldTailPosition: [0, 3], newTailPosition: [0, 12], + hadTail: true, hasTail: true, + wasValid: true, isValid: true, + oldProperties: {}, newProperties: {}, + textChanged: false + }]); + + changes = []; + marker.setTailPosition([0, 6]); + expect(marker.getRange()).toEqual([[0, 6], [0, 9]]); + expect(marker.isReversed()).toBe(false); + expect(changes).toEqual([{ + oldHeadPosition: [0, 9], newHeadPosition: [0, 9], + oldTailPosition: [0, 12], newTailPosition: [0, 6], + hadTail: true, hasTail: true, + wasValid: true, isValid: true, + oldProperties: {}, newProperties: {}, + textChanged: false + }]); + }); + + it("plants the tail of the marker if it does not have a tail", function() { + marker.clearTail(); + expect(marker.hasTail()).toBe(false); + marker.setTailPosition([0, 0]); + expect(marker.hasTail()).toBe(true); + expect(marker.getRange()).toEqual([[0, 0], [0, 9]]); + }); + + it("does not notify ::onDidChange observers and returns false if the position isn't actually changed", function() { + expect(marker.setTailPosition(marker.getTailPosition())).toBe(false); + expect(changes.length).toBe(0); + }); + + it("clips the assigned position", function() { + marker.setTailPosition([100, 100]); + expect(marker.getTailPosition()).toEqual([0, 26]); + }); + }); + + describe("::setRange(range, options)", function() { + it("sets the head and tail position simultaneously, flipping the orientation if the 'isReversed' option is true", function() { + marker.setRange([[0, 8], [0, 12]]); + expect(marker.getRange()).toEqual([[0, 8], [0, 12]]); + expect(marker.isReversed()).toBe(false); + expect(marker.getHeadPosition()).toEqual([0, 12]); + expect(marker.getTailPosition()).toEqual([0, 8]); + expect(changes).toEqual([{ + oldHeadPosition: [0, 9], newHeadPosition: [0, 12], + oldTailPosition: [0, 6], newTailPosition: [0, 8], + hadTail: true, hasTail: true, + wasValid: true, isValid: true, + oldProperties: {}, newProperties: {}, + textChanged: false + }]); + + changes = []; + marker.setRange([[0, 3], [0, 9]], {reversed: true}); + expect(marker.getRange()).toEqual([[0, 3], [0, 9]]); + expect(marker.isReversed()).toBe(true); + expect(marker.getHeadPosition()).toEqual([0, 3]); + expect(marker.getTailPosition()).toEqual([0, 9]); + expect(changes).toEqual([{ + oldHeadPosition: [0, 12], newHeadPosition: [0, 3], + oldTailPosition: [0, 8], newTailPosition: [0, 9], + hadTail: true, hasTail: true, + wasValid: true, isValid: true, + oldProperties: {}, newProperties: {}, + textChanged: false + }]); + }); + + it("plants the tail of the marker if it does not have a tail", function() { + marker.clearTail(); + expect(marker.hasTail()).toBe(false); + marker.setRange([[0, 1], [0, 10]]); + expect(marker.hasTail()).toBe(true); + expect(marker.getRange()).toEqual([[0, 1], [0, 10]]); + }); + + it("clips the assigned range", function() { + marker.setRange([[-100, -100], [100, 100]]); + expect(marker.getRange()).toEqual([[0, 0], [0, 26]]); + }); + + it("emits the right events when called inside of an ::onDidChange handler", function() { + marker.onDidChange(function(change) { + if (marker.getHeadPosition().isEqual([0, 5])) { + marker.setHeadPosition([0, 6]); + } + }); + + marker.setHeadPosition([0, 5]); + + const headPositions = (() => { + const result = []; + for (var {oldHeadPosition, newHeadPosition} of changes) { + result.push({old: oldHeadPosition, new: newHeadPosition}); + } + return result; + })(); + + expect(headPositions).toEqual([ + {old: [0, 9], new: [0, 5]}, + {old: [0, 5], new: [0, 6]} + ]); + }); + + it("throws an error if an invalid range is given", function() { + expect(() => marker.setRange([[0, NaN], [0, 12]])) + .toThrowError("Invalid Point: (0, NaN)"); + + expect(buffer.findMarkers({})).toEqual([marker]); + expect(marker.getRange()).toEqual([[0, 6], [0, 9]]); + }); + }); + + describe("::clearTail() / ::plantTail()", () => { + it("clears the tail / plants the tail at the current head position", function() { + marker.setRange([[0, 6], [0, 9]], {reversed: true}); + + changes = []; + marker.clearTail(); + expect(marker.getRange()).toEqual([[0, 6], [0, 6]]); + expect(marker.hasTail()).toBe(false); + expect(marker.isReversed()).toBe(false); + + expect(changes).toEqual([{ + oldHeadPosition: [0, 6], newHeadPosition: [0, 6], + oldTailPosition: [0, 9], newTailPosition: [0, 6], + hadTail: true, hasTail: false, + wasValid: true, isValid: true, + oldProperties: {}, newProperties: {}, + textChanged: false + }]); + + changes = []; + marker.setHeadPosition([0, 12]); + expect(marker.getRange()).toEqual([[0, 12], [0, 12]]); + expect(changes).toEqual([{ + oldHeadPosition: [0, 6], newHeadPosition: [0, 12], + oldTailPosition: [0, 6], newTailPosition: [0, 12], + hadTail: false, hasTail: false, + wasValid: true, isValid: true, + oldProperties: {}, newProperties: {}, + textChanged: false + }]); + + changes = []; + marker.plantTail(); + expect(marker.hasTail()).toBe(true); + expect(marker.isReversed()).toBe(false); + expect(marker.getRange()).toEqual([[0, 12], [0, 12]]); + expect(changes).toEqual([{ + oldHeadPosition: [0, 12], newHeadPosition: [0, 12], + oldTailPosition: [0, 12], newTailPosition: [0, 12], + hadTail: false, hasTail: true, + wasValid: true, isValid: true, + oldProperties: {}, newProperties: {}, + textChanged: false + }]); + + changes = []; + marker.setHeadPosition([0, 15]); + expect(marker.getRange()).toEqual([[0, 12], [0, 15]]); + expect(changes).toEqual([{ + oldHeadPosition: [0, 12], newHeadPosition: [0, 15], + oldTailPosition: [0, 12], newTailPosition: [0, 12], + hadTail: true, hasTail: true, + wasValid: true, isValid: true, + oldProperties: {}, newProperties: {}, + textChanged: false + }]); + + changes = []; + marker.plantTail(); + expect(marker.getRange()).toEqual([[0, 12], [0, 15]]); + expect(changes).toEqual([]); + }); + }); + + describe("::setProperties(properties)", () => { + it("merges the given properties into the current properties", function() { + marker.setProperties({foo: 1}); + expect(marker.getProperties()).toEqual({foo: 1}); + marker.setProperties({bar: 2}); + expect(marker.getProperties()).toEqual({foo: 1, bar: 2}); + expect(markersUpdatedCount).toBe(2); + }); + }); + }); + + describe("indirect updates (due to buffer changes)", function() { + let allStrategies, neverMarker, surroundMarker, overlapMarker, insideMarker, touchMarker; + + beforeEach(function() { + overlapMarker = buffer.markRange([[0, 6], [0, 9]], {invalidate: 'overlap'}); + neverMarker = overlapMarker.copy({invalidate: 'never'}); + surroundMarker = overlapMarker.copy({invalidate: 'surround'}); + insideMarker = overlapMarker.copy({invalidate: 'inside'}); + touchMarker = overlapMarker.copy({invalidate: 'touch'}); + allStrategies = [neverMarker, surroundMarker, overlapMarker, insideMarker, touchMarker]; + markersUpdatedCount = 0; + }); + + it("defers notifying Marker::onDidChange observers until after notifying Buffer::onDidChange observers", function() { + for (let marker of allStrategies) { + marker.changes = []; + marker.onDidChange(change => marker.changes.push(change)); + } + + let changedCount = 0; + const changeSubscription = buffer.onDidChange(function(change) { + changedCount++; + expect(markersUpdatedCount).toBe(0); + for (let marker of allStrategies) { + expect(marker.getRange()).toEqual([[0, 8], [0, 11]]); + expect(marker.isValid()).toBe(true); + expect(marker.changes.length).toBe(0); + } + }); + + buffer.setTextInRange([[0, 1], [0, 2]], "ABC"); + + expect(changedCount).toBe(1); + + for (let marker of allStrategies) { + expect(marker.changes).toEqual([{ + oldHeadPosition: [0, 9], newHeadPosition: [0, 11], + oldTailPosition: [0, 6], newTailPosition: [0, 8], + hadTail: true, hasTail: true, + wasValid: true, isValid: true, + oldProperties: {}, newProperties: {}, + textChanged: true + }]); + } + expect(markersUpdatedCount).toBe(1); + + for (marker of allStrategies) { marker.changes = []; } + changeSubscription.dispose(); + changedCount = 0; + markersUpdatedCount = 0; + buffer.onDidChange(function(change) { + changedCount++; + expect(markersUpdatedCount).toBe(0); + for (marker of allStrategies) { + expect(marker.getRange()).toEqual([[0, 6], [0, 9]]); + expect(marker.isValid()).toBe(true); + expect(marker.changes.length).toBe(0); + } + }); + }); + + it("notifies ::onDidUpdateMarkers observers even if there are no Marker::onDidChange observers", function() { + expect(markersUpdatedCount).toBe(0); + buffer.insert([0, 0], "123"); + expect(markersUpdatedCount).toBe(1); + overlapMarker.setRange([[0, 1], [0, 2]]); + expect(markersUpdatedCount).toBe(2); + }); + + it("emits onDidChange events when undoing/redoing text changes that move the marker", function() { + const marker = buffer.markRange([[0, 4], [0, 8]]); + buffer.insert([0, 0], 'ABCD'); + + const changes = []; + marker.onDidChange(change => changes.push(change)); + buffer.undo(); + expect(changes.length).toBe(1); + expect(changes[0].newHeadPosition).toEqual([0, 8]); + buffer.redo(); + expect(changes.length).toBe(2); + expect(changes[1].newHeadPosition).toEqual([0, 12]); + }); + + describe("when a change precedes a marker", () => { + it("shifts the marker based on the characters inserted or removed by the change", function() { + let marker; + buffer.setTextInRange([[0, 1], [0, 2]], "ABC"); + for (marker of allStrategies) { + expect(marker.getRange()).toEqual([[0, 8], [0, 11]]); + expect(marker.isValid()).toBe(true); + } + + buffer.setTextInRange([[0, 1], [0, 1]], '\nDEF'); + for (marker of allStrategies) { + expect(marker.getRange()).toEqual([[1, 10], [1, 13]]); + expect(marker.isValid()).toBe(true); + } + }) + }); + + describe("when a change follows a marker", () => { + it("does not shift the marker", function() { + buffer.setTextInRange([[0, 10], [0, 12]], "ABC"); + for (var marker of allStrategies) { + expect(marker.getRange()).toEqual([[0, 6], [0, 9]]); + expect(marker.isValid()).toBe(true); + } + }) + }); + + describe("when a change starts at a marker's start position", function() { + describe("when the marker has a tail", () => { + it("interprets the change as being inside the marker for all invalidation strategies", function() { + buffer.setTextInRange([[0, 6], [0, 7]], "ABC"); + + for (var marker of difference(allStrategies, [insideMarker, touchMarker])) { + expect(marker.getRange()).toEqual([[0, 6], [0, 11]]); + expect(marker.isValid()).toBe(true); + } + + expect(insideMarker.getRange()).toEqual([[0, 9], [0, 11]]); + expect(insideMarker.isValid()).toBe(false); + expect(touchMarker.getRange()).toEqual([[0, 6], [0, 11]]); + expect(touchMarker.isValid()).toBe(false); + }) + }); + + describe("when the marker has no tail", () => { + it("interprets the change as being outside the marker for all invalidation strategies", function() { + let marker; + for (marker of allStrategies) { + marker.setRange([[0, 6], [0, 11]], {reversed: true}); + marker.clearTail(); + expect(marker.getRange()).toEqual([[0, 6], [0, 6]]); + } + + buffer.setTextInRange([[0, 6], [0, 6]], "ABC"); + + for (marker of difference(allStrategies, [touchMarker])) { + expect(marker.getRange()).toEqual([[0, 9], [0, 9]]); + expect(marker.isValid()).toBe(true); + } + + expect(touchMarker.getRange()).toEqual([[0, 9], [0, 9]]); + expect(touchMarker.isValid()).toBe(false); + + buffer.setTextInRange([[0, 9], [0, 9]], "DEF"); + + for (marker of difference(allStrategies, [touchMarker])) { + expect(marker.getRange()).toEqual([[0, 12], [0, 12]]); + expect(marker.isValid()).toBe(true); + } + + expect(touchMarker.getRange()).toEqual([[0, 12], [0, 12]]); + expect(touchMarker.isValid()).toBe(false); + }); + }); + }); + + describe("when a change ends at a marker's start position but starts before it", () => { + it("interprets the change as being outside the marker for all invalidation strategies", function() { + buffer.setTextInRange([[0, 4], [0, 6]], "ABC"); + + for (var marker of difference(allStrategies, [touchMarker])) { + expect(marker.getRange()).toEqual([[0, 7], [0, 10]]); + expect(marker.isValid()).toBe(true); + } + + expect(touchMarker.getRange()).toEqual([[0, 7], [0, 10]]); + expect(touchMarker.isValid()).toBe(false); + }); + }); + + describe("when a change starts and ends at a marker's start position", () => { + it("interprets the change as being inside the marker for all invalidation strategies except 'inside'", function() { + buffer.insert([0, 6], "ABC"); + + for (var marker of difference(allStrategies, [insideMarker, touchMarker])) { + expect(marker.getRange()).toEqual([[0, 6], [0, 12]]); + expect(marker.isValid()).toBe(true); + } + + expect(insideMarker.getRange()).toEqual([[0, 9], [0, 12]]); + expect(insideMarker.isValid()).toBe(true); + + expect(touchMarker.getRange()).toEqual([[0, 6], [0, 12]]); + expect(touchMarker.isValid()).toBe(false); + }); + }); + + describe("when a change starts at a marker's end position", function() { + describe("when the change is an insertion", () => { + it("interprets the change as being inside the marker for all invalidation strategies except 'inside'", function() { + buffer.setTextInRange([[0, 9], [0, 9]], "ABC"); + + for (var marker of difference(allStrategies, [insideMarker, touchMarker])) { + expect(marker.getRange()).toEqual([[0, 6], [0, 12]]); + expect(marker.isValid()).toBe(true); + } + + expect(insideMarker.getRange()).toEqual([[0, 6], [0, 9]]); + expect(insideMarker.isValid()).toBe(true); + + expect(touchMarker.getRange()).toEqual([[0, 6], [0, 12]]); + expect(touchMarker.isValid()).toBe(false); + }); + }); + + describe("when the change replaces some existing text", () => { + it("interprets the change as being outside the marker for all invalidation strategies", function() { + buffer.setTextInRange([[0, 9], [0, 11]], "ABC"); + + for (var marker of difference(allStrategies, [touchMarker])) { + expect(marker.getRange()).toEqual([[0, 6], [0, 9]]); + expect(marker.isValid()).toBe(true); + } + + expect(touchMarker.getRange()).toEqual([[0, 6], [0, 9]]); + expect(touchMarker.isValid()).toBe(false); + }) + }); + }); + + describe("when a change surrounds a marker", () => { + it("truncates the marker to the end of the change and invalidates every invalidation strategy except 'never'", function() { + let marker; + buffer.setTextInRange([[0, 5], [0, 10]], "ABC"); + + for (marker of allStrategies) { + expect(marker.getRange()).toEqual([[0, 8], [0, 8]]); + } + + for (marker of difference(allStrategies, [neverMarker])) { + expect(marker.isValid()).toBe(false); + } + + expect(neverMarker.isValid()).toBe(true); + }) + }); + + describe("when a change is inside a marker", () => { + it("adjusts the marker's end position and invalidates markers with an 'inside' or 'touch' strategy", function() { + let marker; + buffer.setTextInRange([[0, 7], [0, 8]], "AB"); + + for (marker of allStrategies) { + expect(marker.getRange()).toEqual([[0, 6], [0, 10]]); + } + + for (marker of difference(allStrategies, [insideMarker, touchMarker])) { + expect(marker.isValid()).toBe(true); + } + + expect(insideMarker.isValid()).toBe(false); + expect(touchMarker.isValid()).toBe(false); + }); + }); + + describe("when a change overlaps the start of a marker", () => { + it("moves the start of the marker to the end of the change and invalidates the marker if its stategy is 'overlap', 'inside', or 'touch'", function() { + buffer.setTextInRange([[0, 5], [0, 7]], "ABC"); + + for (var marker of allStrategies) { + expect(marker.getRange()).toEqual([[0, 8], [0, 10]]); + } + + expect(neverMarker.isValid()).toBe(true); + expect(surroundMarker.isValid()).toBe(true); + expect(overlapMarker.isValid()).toBe(false); + expect(insideMarker.isValid()).toBe(false); + expect(touchMarker.isValid()).toBe(false); + }); + }); + + describe("when a change overlaps the end of a marker", () => { + it("moves the end of the marker to the end of the change and invalidates the marker if its stategy is 'overlap', 'inside', or 'touch'", function() { + buffer.setTextInRange([[0, 8], [0, 10]], "ABC"); + + for (var marker of allStrategies) { + expect(marker.getRange()).toEqual([[0, 6], [0, 11]]); + } + + expect(neverMarker.isValid()).toBe(true); + expect(surroundMarker.isValid()).toBe(true); + expect(overlapMarker.isValid()).toBe(false); + expect(insideMarker.isValid()).toBe(false); + expect(touchMarker.isValid()).toBe(false); + }); + }); + + describe("when multiple changes occur in a transaction", () => { + it("emits one change event for each marker that was indirectly updated", function() { + for (let strategy of allStrategies) { + strategy.changes = []; + strategy.onDidChange(change => strategy.changes.push(change)); + } + + buffer.transact(function() { + buffer.insert([0, 7], "."); + buffer.append("!"); + + for (let strategy of allStrategies) { + expect(strategy.changes.length).toBe(0); + } + + neverMarker.setRange([[0, 0], [0, 1]]); + }); + + expect(neverMarker.changes).toEqual([{ + oldHeadPosition: [0, 9], + newHeadPosition: [0, 1], + oldTailPosition: [0, 6], + newTailPosition: [0, 0], + wasValid: true, + isValid: true, + hadTail: true, + hasTail: true, + oldProperties: {}, + newProperties: {}, + textChanged: false + }]); + + expect(insideMarker.changes).toEqual([{ + oldHeadPosition: [0, 9], + newHeadPosition: [0, 10], + oldTailPosition: [0, 6], + newTailPosition: [0, 6], + wasValid: true, + isValid: false, + hadTail: true, + hasTail: true, + oldProperties: {}, + newProperties: {}, + textChanged: true + }]); + }); + }); + }); + + describe("destruction", function() { + it("removes the marker from the buffer, marks it destroyed and invalid, and notifies ::onDidDestroy observers", function() { + let destroyedHandler; + const marker = buffer.markRange([[0, 3], [0, 6]]); + expect(buffer.getMarker(marker.id)).toBe(marker); + marker.onDidDestroy(destroyedHandler = jasmine.createSpy("destroyedHandler")); + + marker.destroy(); + + expect(destroyedHandler.calls.count()).toBe(1); + expect(buffer.getMarker(marker.id)).toBeUndefined(); + expect(marker.isDestroyed()).toBe(true); + expect(marker.isValid()).toBe(false); + expect(marker.getRange()).toEqual([[0, 0], [0, 0]]); + }); + + it("handles markers deleted in event handlers", function() { + let marker1 = buffer.markRange([[0, 3], [0, 6]]); + let marker2 = marker1.copy(); + let marker3 = marker1.copy(); + + marker1.onDidChange(function() { + marker1.destroy(); + marker2.destroy(); + marker3.destroy(); + }); + + // doesn't blow up. + buffer.insert([0, 0], "!"); + + marker1 = buffer.markRange([[0, 3], [0, 6]]); + marker2 = marker1.copy(); + marker3 = marker1.copy(); + + marker1.onDidChange(function() { + marker1.destroy(); + marker2.destroy(); + marker3.destroy(); + }); + + // doesn't blow up. + buffer.undo(); + }); + + it("does not reinsert the marker if its range is later updated", function() { + const marker = buffer.markRange([[0, 3], [0, 6]]); + marker.destroy(); + expect(buffer.findMarkers({intersectsRow: 0})).toEqual([]); + marker.setRange([[0, 0], [0, 9]]); + expect(buffer.findMarkers({intersectsRow: 0})).toEqual([]); + }); + + it("does not blow up when destroy is called twice", function() { + const marker = buffer.markRange([[0, 3], [0, 6]]); + marker.destroy(); + marker.destroy(); + }); + }); + + describe("TextBuffer::findMarkers(properties)", function() { + let marker1, marker2, marker3, marker4; + + beforeEach(function() { + marker1 = buffer.markRange([[0, 0], [0, 3]], {class: 'a'}); + marker2 = buffer.markRange([[0, 0], [0, 5]], {class: 'a', invalidate: 'surround'}); + marker3 = buffer.markRange([[0, 4], [0, 7]], {class: 'a'}); + marker4 = buffer.markRange([[0, 0], [0, 7]], {class: 'b', invalidate: 'never'}); + }); + + it("can find markers based on custom properties", function() { + expect(buffer.findMarkers({class: 'a'})).toEqual([marker2, marker1, marker3]); + expect(buffer.findMarkers({class: 'b'})).toEqual([marker4]); + }); + + it("can find markers based on their invalidation strategy", function() { + expect(buffer.findMarkers({invalidate: 'overlap'})).toEqual([marker1, marker3]); + expect(buffer.findMarkers({invalidate: 'surround'})).toEqual([marker2]); + expect(buffer.findMarkers({invalidate: 'never'})).toEqual([marker4]); + }); + + it("can find markers that start or end at a given position", function() { + expect(buffer.findMarkers({startPosition: [0, 0]})).toEqual([marker4, marker2, marker1]); + expect(buffer.findMarkers({startPosition: [0, 0], class: 'a'})).toEqual([marker2, marker1]); + expect(buffer.findMarkers({startPosition: [0, 0], endPosition: [0, 3], class: 'a'})).toEqual([marker1]); + expect(buffer.findMarkers({startPosition: [0, 4], endPosition: [0, 7]})).toEqual([marker3]); + expect(buffer.findMarkers({endPosition: [0, 7]})).toEqual([marker4, marker3]); + expect(buffer.findMarkers({endPosition: [0, 7], class: 'b'})).toEqual([marker4]); + }); + + it("can find markers that start or end at a given range", function() { + expect(buffer.findMarkers({startsInRange: [[0, 0], [0, 4]]})).toEqual([marker4, marker2, marker1, marker3]); + expect(buffer.findMarkers({startsInRange: [[0, 0], [0, 4]], class: 'a'})).toEqual([marker2, marker1, marker3]); + expect(buffer.findMarkers({startsInRange: [[0, 0], [0, 4]], endsInRange: [[0, 3], [0, 6]]})).toEqual([marker2, marker1]); + expect(buffer.findMarkers({endsInRange: [[0, 5], [0, 7]]})).toEqual([marker4, marker2, marker3]); + }); + + it("can find markers that contain a given point", function() { + expect(buffer.findMarkers({containsPosition: [0, 0]})).toEqual([marker4, marker2, marker1]); + expect(buffer.findMarkers({containsPoint: [0, 0]})).toEqual([marker4, marker2, marker1]); + expect(buffer.findMarkers({containsPoint: [0, 1], class: 'a'})).toEqual([marker2, marker1]); + expect(buffer.findMarkers({containsPoint: [0, 4]})).toEqual([marker4, marker2, marker3]); + }); + + it("can find markers that contain a given range", function() { + expect(buffer.findMarkers({containsRange: [[0, 1], [0, 4]]})).toEqual([marker4, marker2]); + expect(buffer.findMarkers({containsRange: [[0, 4], [0, 1]]})).toEqual([marker4, marker2]); + expect(buffer.findMarkers({containsRange: [[0, 1], [0, 3]]})).toEqual([marker4, marker2, marker1]); + expect(buffer.findMarkers({containsRange: [[0, 6], [0, 7]]})).toEqual([marker4, marker3]); + }); + + it("can find markers that intersect a given range", function() { + expect(buffer.findMarkers({intersectsRange: [[0, 4], [0, 6]]})).toEqual([marker4, marker2, marker3]); + expect(buffer.findMarkers({intersectsRange: [[0, 0], [0, 2]]})).toEqual([marker4, marker2, marker1]); + }); + + it("can find markers that start or end at a given row", function() { + buffer.setTextInRange([[0, 7], [0, 7]], '\n'); + buffer.setTextInRange([[0, 3], [0, 4]], ' \n'); + expect(buffer.findMarkers({startRow: 0})).toEqual([marker4, marker2, marker1]); + expect(buffer.findMarkers({startRow: 1})).toEqual([marker3]); + expect(buffer.findMarkers({endRow: 2})).toEqual([marker4, marker3]); + expect(buffer.findMarkers({startRow: 0, endRow: 2})).toEqual([marker4]); + }); + + it("can find markers that intersect a given row", function() { + buffer.setTextInRange([[0, 7], [0, 7]], '\n'); + buffer.setTextInRange([[0, 3], [0, 4]], ' \n'); + expect(buffer.findMarkers({intersectsRow: 0})).toEqual([marker4, marker2, marker1]); + expect(buffer.findMarkers({intersectsRow: 1})).toEqual([marker4, marker2, marker3]); + }); + + it("can find markers that intersect a given range", function() { + buffer.setTextInRange([[0, 7], [0, 7]], '\n'); + buffer.setTextInRange([[0, 3], [0, 4]], ' \n'); + expect(buffer.findMarkers({intersectsRowRange: [1, 2]})).toEqual([marker4, marker2, marker3]); + }); + + it("can find markers that are contained within a certain range, inclusive", function() { + expect(buffer.findMarkers({containedInRange: [[0, 0], [0, 6]]})).toEqual([marker2, marker1]); + expect(buffer.findMarkers({containedInRange: [[0, 4], [0, 7]]})).toEqual([marker3]); + }); + }); +}); diff --git a/spec/point-spec.coffee b/spec/point-spec.coffee deleted file mode 100644 index 317a4d7152..0000000000 --- a/spec/point-spec.coffee +++ /dev/null @@ -1,196 +0,0 @@ -Point = require '../src/point' - -describe "Point", -> - beforeEach -> - jasmine.addCustomEqualityTester(require("underscore-plus").isEqual) - - describe "::negate()", -> - it "should negate the row and column", -> - expect(new Point( 0, 0).negate().toString()).toBe "(0, 0)" - expect(new Point( 1, 2).negate().toString()).toBe "(-1, -2)" - expect(new Point(-1, -2).negate().toString()).toBe "(1, 2)" - expect(new Point(-1, 2).negate().toString()).toBe "(1, -2)" - - describe "::fromObject(object, copy)", -> - it "returns a new Point if object is point-compatible array ", -> - expect(Point.fromObject([1, 3])).toEqual Point(1, 3) - expect(Point.fromObject([Infinity, Infinity])).toEqual Point.INFINITY - - it "returns the copy of object if it is an instanceof Point", -> - origin = Point(0, 0) - expect(Point.fromObject(origin, false) is origin).toBe true - expect(Point.fromObject(origin, true) is origin).toBe false - - describe "::copy()", -> - it "returns a copy of the object", -> - expect(Point(3, 4).copy()).toEqual Point(3, 4) - expect(Point.ZERO.copy()).toEqual [0, 0] - - describe "::negate()", -> - it "returns a new point with row and column negated", -> - expect(Point(3, 4).negate()).toEqual Point(-3, -4) - expect(Point.ZERO.negate()).toEqual [0, 0] - - describe "::freeze()", -> - it "makes the Point object immutable", -> - expect(Object.isFrozen(Point(3, 4).freeze())).toBe true - expect(Object.isFrozen(Point.ZERO.freeze())).toBe true - - describe "::compare(other)", -> - it "returns -1 for <, 0 for =, 1 for > comparisions", -> - expect(Point(2, 3).compare(Point(2, 6))).toBe -1 - expect(Point(2, 3).compare(Point(3, 4))).toBe -1 - expect(Point(1, 1).compare(Point(1, 1))).toBe 0 - expect(Point(2, 3).compare(Point(2, 0))).toBe 1 - expect(Point(2, 3).compare(Point(1, 3))).toBe 1 - - expect(Point(2, 3).compare([2, 6])).toBe -1 - expect(Point(2, 3).compare([3, 4])).toBe -1 - expect(Point(1, 1).compare([1, 1])).toBe 0 - expect(Point(2, 3).compare([2, 0])).toBe 1 - expect(Point(2, 3).compare([1, 3])).toBe 1 - - describe "::isLessThan(other)", -> - it "returns a boolean indicating whether a point precedes the given Point ", -> - expect(Point(2, 3).isLessThan(Point(2, 5))).toBe true - expect(Point(2, 3).isLessThan(Point(3, 4))).toBe true - expect(Point(2, 3).isLessThan(Point(2, 3))).toBe false - expect(Point(2, 3).isLessThan(Point(2, 1))).toBe false - expect(Point(2, 3).isLessThan(Point(1, 2))).toBe false - - expect(Point(2, 3).isLessThan([2, 5])).toBe true - expect(Point(2, 3).isLessThan([3, 4])).toBe true - expect(Point(2, 3).isLessThan([2, 3])).toBe false - expect(Point(2, 3).isLessThan([2, 1])).toBe false - expect(Point(2, 3).isLessThan([1, 2])).toBe false - - describe "::isLessThanOrEqual(other)", -> - it "returns a boolean indicating whether a point precedes or equal the given Point ", -> - expect(Point(2, 3).isLessThanOrEqual(Point(2, 5))).toBe true - expect(Point(2, 3).isLessThanOrEqual(Point(3, 4))).toBe true - expect(Point(2, 3).isLessThanOrEqual(Point(2, 3))).toBe true - expect(Point(2, 3).isLessThanOrEqual(Point(2, 1))).toBe false - expect(Point(2, 3).isLessThanOrEqual(Point(1, 2))).toBe false - - expect(Point(2, 3).isLessThanOrEqual([2, 5])).toBe true - expect(Point(2, 3).isLessThanOrEqual([3, 4])).toBe true - expect(Point(2, 3).isLessThanOrEqual([2, 3])).toBe true - expect(Point(2, 3).isLessThanOrEqual([2, 1])).toBe false - expect(Point(2, 3).isLessThanOrEqual([1, 2])).toBe false - - describe "::isGreaterThan(other)", -> - it "returns a boolean indicating whether a point follows the given Point ", -> - expect(Point(2, 3).isGreaterThan(Point(2, 5))).toBe false - expect(Point(2, 3).isGreaterThan(Point(3, 4))).toBe false - expect(Point(2, 3).isGreaterThan(Point(2, 3))).toBe false - expect(Point(2, 3).isGreaterThan(Point(2, 1))).toBe true - expect(Point(2, 3).isGreaterThan(Point(1, 2))).toBe true - - expect(Point(2, 3).isGreaterThan([2, 5])).toBe false - expect(Point(2, 3).isGreaterThan([3, 4])).toBe false - expect(Point(2, 3).isGreaterThan([2, 3])).toBe false - expect(Point(2, 3).isGreaterThan([2, 1])).toBe true - expect(Point(2, 3).isGreaterThan([1, 2])).toBe true - - describe "::isGreaterThanOrEqual(other)", -> - it "returns a boolean indicating whether a point follows or equal the given Point ", -> - expect(Point(2, 3).isGreaterThanOrEqual(Point(2, 5))).toBe false - expect(Point(2, 3).isGreaterThanOrEqual(Point(3, 4))).toBe false - expect(Point(2, 3).isGreaterThanOrEqual(Point(2, 3))).toBe true - expect(Point(2, 3).isGreaterThanOrEqual(Point(2, 1))).toBe true - expect(Point(2, 3).isGreaterThanOrEqual(Point(1, 2))).toBe true - - expect(Point(2, 3).isGreaterThanOrEqual([2, 5])).toBe false - expect(Point(2, 3).isGreaterThanOrEqual([3, 4])).toBe false - expect(Point(2, 3).isGreaterThanOrEqual([2, 3])).toBe true - expect(Point(2, 3).isGreaterThanOrEqual([2, 1])).toBe true - expect(Point(2, 3).isGreaterThanOrEqual([1, 2])).toBe true - - describe "::isEqual()", -> - it "returns if whether two points are equal", -> - expect(Point(1, 1).isEqual(Point(1, 1))).toBe true - expect(Point(1, 1).isEqual([1, 1])).toBe true - expect(Point(1, 2).isEqual(Point(3, 3))).toBe false - expect(Point(1, 2).isEqual([3, 3])).toBe false - - describe "::isPositive()", -> - it "returns true if the point represents a forward traversal", -> - expect(Point(-1, -1).isPositive()).toBe false - expect(Point(-1, 0).isPositive()).toBe false - expect(Point(-1, Infinity).isPositive()).toBe false - expect(Point(0, 0).isPositive()).toBe false - - expect(Point(0, 1).isPositive()).toBe true - expect(Point(5, 0).isPositive()).toBe true - expect(Point(5, -1).isPositive()).toBe true - - describe "::isZero()", -> - it "returns true if the point is zero", -> - expect(Point(1, 1).isZero()).toBe false - expect(Point(0, 1).isZero()).toBe false - expect(Point(1, 0).isZero()).toBe false - expect(Point(0, 0).isZero()).toBe true - - describe "::min(a, b)", -> - it "returns the minimum of two points", -> - expect(Point.min(Point(3, 4), Point(1, 1))).toEqual Point(1, 1) - expect(Point.min(Point(1, 2), Point(5, 6))).toEqual Point(1, 2) - expect(Point.min([3, 4], [1, 1])).toEqual [1, 1] - expect(Point.min([1, 2], [5, 6])).toEqual [1, 2] - - describe "::max(a, b)", -> - it "returns the minimum of two points", -> - expect(Point.max(Point(3, 4), Point(1, 1))).toEqual Point(3, 4) - expect(Point.max(Point(1, 2), Point(5, 6))).toEqual Point(5, 6) - expect(Point.max([3, 4], [1, 1])).toEqual [3, 4] - expect(Point.max([1, 2], [5, 6])).toEqual [5, 6] - - describe "::translate(delta)", -> - it "returns a new point by adding corresponding coordinates", -> - expect(Point(1, 1).translate(Point(2, 3))).toEqual Point(3, 4) - expect(Point.INFINITY.translate(Point(2, 3))).toEqual Point.INFINITY - - expect(Point.ZERO.translate([5, 6])).toEqual [5, 6] - expect(Point(1, 1).translate([3, 4])).toEqual [4, 5] - - describe "::traverse(delta)", -> - it "returns a new point by traversing given rows and columns", -> - expect(Point(2, 3).traverse(Point(0, 3))).toEqual Point(2, 6) - expect(Point(2, 3).traverse([0, 3])).toEqual [2, 6] - - expect(Point(1, 3).traverse(Point(4, 2))).toEqual [5, 2] - expect(Point(1, 3).traverse([5, 4])).toEqual [6, 4] - - describe "::traversalFrom(other)", -> - it "returns a point that other has to traverse to get to given point", -> - expect(Point(2, 5).traversalFrom(Point(2, 3))).toEqual Point(0, 2) - expect(Point(2, 3).traversalFrom(Point(2, 5))).toEqual Point(0, -2) - expect(Point(2, 3).traversalFrom(Point(2, 3))).toEqual Point(0, 0) - - expect(Point(3, 4).traversalFrom(Point(2, 3))).toEqual Point(1, 4) - expect(Point(2, 3).traversalFrom(Point(3, 5))).toEqual Point(-1, 3) - - expect(Point(2, 5).traversalFrom([2, 3])).toEqual [0, 2] - expect(Point(2, 3).traversalFrom([2, 5])).toEqual [0, -2] - expect(Point(2, 3).traversalFrom([2, 3])).toEqual [0, 0] - - expect(Point(3, 4).traversalFrom([2, 3])).toEqual [1, 4] - expect(Point(2, 3).traversalFrom([3, 5])).toEqual [-1, 3] - - describe "::toArray()", -> - it "returns an array of row and column", -> - expect(Point(1, 3).toArray()).toEqual [1, 3] - expect(Point.ZERO.toArray()).toEqual [0, 0] - expect(Point.INFINITY.toArray()).toEqual [Infinity, Infinity] - - describe "::serialize()", -> - it "returns an array of row and column", -> - expect(Point(1, 3).serialize()).toEqual [1, 3] - expect(Point.ZERO.serialize()).toEqual [0, 0] - expect(Point.INFINITY.serialize()).toEqual [Infinity, Infinity] - - describe "::toString()", -> - it "returns string representation of Point", -> - expect(Point(4, 5).toString()).toBe "(4, 5)" - expect(Point.ZERO.toString()).toBe "(0, 0)" - expect(Point.INFINITY.toString()).toBe "(Infinity, Infinity)" diff --git a/spec/point-spec.js b/spec/point-spec.js new file mode 100644 index 0000000000..e3c16c53e8 --- /dev/null +++ b/spec/point-spec.js @@ -0,0 +1,204 @@ +/* +* decaffeinate suggestions: +* DS102: Remove unnecessary code created because of implicit returns +* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md +*/ +const Point = require('../src/point'); + +describe("Point", function() { + beforeEach(() => jasmine.addCustomEqualityTester(require("underscore-plus").isEqual)); + + describe("::negate()", () => it("should negate the row and column", function() { + expect(new Point( 0, 0).negate().toString()).toBe("(0, 0)"); + expect(new Point( 1, 2).negate().toString()).toBe("(-1, -2)"); + expect(new Point(-1, -2).negate().toString()).toBe("(1, 2)"); + expect(new Point(-1, 2).negate().toString()).toBe("(1, -2)"); + })); + + describe("::fromObject(object, copy)", function() { + it("returns a new Point if object is point-compatible array ", function() { + expect(Point.fromObject([1, 3])).toEqual(Point(1, 3)); + expect(Point.fromObject([Infinity, Infinity])).toEqual(Point.INFINITY); + }); + + it("returns the copy of object if it is an instanceof Point", function() { + const origin = Point(0, 0); + expect(Point.fromObject(origin, false) === origin).toBe(true); + expect(Point.fromObject(origin, true) === origin).toBe(false); + }); + }); + + describe("::copy()", () => it("returns a copy of the object", function() { + expect(Point(3, 4).copy()).toEqual(Point(3, 4)); + expect(Point.ZERO.copy()).toEqual([0, 0]); + })); + + describe("::negate()", () => it("returns a new point with row and column negated", function() { + expect(Point(3, 4).negate()).toEqual(Point(-3, -4)); + expect(Point.ZERO.negate()).toEqual([0, 0]); + })); + + describe("::freeze()", () => it("makes the Point object immutable", function() { + expect(Object.isFrozen(Point(3, 4).freeze())).toBe(true); + expect(Object.isFrozen(Point.ZERO.freeze())).toBe(true); + })); + + describe("::compare(other)", () => it("returns -1 for <, 0 for =, 1 for > comparisions", function() { + expect(Point(2, 3).compare(Point(2, 6))).toBe(-1); + expect(Point(2, 3).compare(Point(3, 4))).toBe(-1); + expect(Point(1, 1).compare(Point(1, 1))).toBe(0); + expect(Point(2, 3).compare(Point(2, 0))).toBe(1); + expect(Point(2, 3).compare(Point(1, 3))).toBe(1); + + expect(Point(2, 3).compare([2, 6])).toBe(-1); + expect(Point(2, 3).compare([3, 4])).toBe(-1); + expect(Point(1, 1).compare([1, 1])).toBe(0); + expect(Point(2, 3).compare([2, 0])).toBe(1); + expect(Point(2, 3).compare([1, 3])).toBe(1); + })); + + describe("::isLessThan(other)", () => it("returns a boolean indicating whether a point precedes the given Point ", function() { + expect(Point(2, 3).isLessThan(Point(2, 5))).toBe(true); + expect(Point(2, 3).isLessThan(Point(3, 4))).toBe(true); + expect(Point(2, 3).isLessThan(Point(2, 3))).toBe(false); + expect(Point(2, 3).isLessThan(Point(2, 1))).toBe(false); + expect(Point(2, 3).isLessThan(Point(1, 2))).toBe(false); + + expect(Point(2, 3).isLessThan([2, 5])).toBe(true); + expect(Point(2, 3).isLessThan([3, 4])).toBe(true); + expect(Point(2, 3).isLessThan([2, 3])).toBe(false); + expect(Point(2, 3).isLessThan([2, 1])).toBe(false); + expect(Point(2, 3).isLessThan([1, 2])).toBe(false); + })); + + describe("::isLessThanOrEqual(other)", () => it("returns a boolean indicating whether a point precedes or equal the given Point ", function() { + expect(Point(2, 3).isLessThanOrEqual(Point(2, 5))).toBe(true); + expect(Point(2, 3).isLessThanOrEqual(Point(3, 4))).toBe(true); + expect(Point(2, 3).isLessThanOrEqual(Point(2, 3))).toBe(true); + expect(Point(2, 3).isLessThanOrEqual(Point(2, 1))).toBe(false); + expect(Point(2, 3).isLessThanOrEqual(Point(1, 2))).toBe(false); + + expect(Point(2, 3).isLessThanOrEqual([2, 5])).toBe(true); + expect(Point(2, 3).isLessThanOrEqual([3, 4])).toBe(true); + expect(Point(2, 3).isLessThanOrEqual([2, 3])).toBe(true); + expect(Point(2, 3).isLessThanOrEqual([2, 1])).toBe(false); + expect(Point(2, 3).isLessThanOrEqual([1, 2])).toBe(false); + })); + + describe("::isGreaterThan(other)", () => it("returns a boolean indicating whether a point follows the given Point ", function() { + expect(Point(2, 3).isGreaterThan(Point(2, 5))).toBe(false); + expect(Point(2, 3).isGreaterThan(Point(3, 4))).toBe(false); + expect(Point(2, 3).isGreaterThan(Point(2, 3))).toBe(false); + expect(Point(2, 3).isGreaterThan(Point(2, 1))).toBe(true); + expect(Point(2, 3).isGreaterThan(Point(1, 2))).toBe(true); + + expect(Point(2, 3).isGreaterThan([2, 5])).toBe(false); + expect(Point(2, 3).isGreaterThan([3, 4])).toBe(false); + expect(Point(2, 3).isGreaterThan([2, 3])).toBe(false); + expect(Point(2, 3).isGreaterThan([2, 1])).toBe(true); + expect(Point(2, 3).isGreaterThan([1, 2])).toBe(true); + })); + + describe("::isGreaterThanOrEqual(other)", () => it("returns a boolean indicating whether a point follows or equal the given Point ", function() { + expect(Point(2, 3).isGreaterThanOrEqual(Point(2, 5))).toBe(false); + expect(Point(2, 3).isGreaterThanOrEqual(Point(3, 4))).toBe(false); + expect(Point(2, 3).isGreaterThanOrEqual(Point(2, 3))).toBe(true); + expect(Point(2, 3).isGreaterThanOrEqual(Point(2, 1))).toBe(true); + expect(Point(2, 3).isGreaterThanOrEqual(Point(1, 2))).toBe(true); + + expect(Point(2, 3).isGreaterThanOrEqual([2, 5])).toBe(false); + expect(Point(2, 3).isGreaterThanOrEqual([3, 4])).toBe(false); + expect(Point(2, 3).isGreaterThanOrEqual([2, 3])).toBe(true); + expect(Point(2, 3).isGreaterThanOrEqual([2, 1])).toBe(true); + expect(Point(2, 3).isGreaterThanOrEqual([1, 2])).toBe(true); + })); + + describe("::isEqual()", () => it("returns if whether two points are equal", function() { + expect(Point(1, 1).isEqual(Point(1, 1))).toBe(true); + expect(Point(1, 1).isEqual([1, 1])).toBe(true); + expect(Point(1, 2).isEqual(Point(3, 3))).toBe(false); + expect(Point(1, 2).isEqual([3, 3])).toBe(false); + })); + + describe("::isPositive()", () => it("returns true if the point represents a forward traversal", function() { + expect(Point(-1, -1).isPositive()).toBe(false); + expect(Point(-1, 0).isPositive()).toBe(false); + expect(Point(-1, Infinity).isPositive()).toBe(false); + expect(Point(0, 0).isPositive()).toBe(false); + + expect(Point(0, 1).isPositive()).toBe(true); + expect(Point(5, 0).isPositive()).toBe(true); + expect(Point(5, -1).isPositive()).toBe(true); + })); + + describe("::isZero()", () => it("returns true if the point is zero", function() { + expect(Point(1, 1).isZero()).toBe(false); + expect(Point(0, 1).isZero()).toBe(false); + expect(Point(1, 0).isZero()).toBe(false); + expect(Point(0, 0).isZero()).toBe(true); + })); + + describe("::min(a, b)", () => it("returns the minimum of two points", function() { + expect(Point.min(Point(3, 4), Point(1, 1))).toEqual(Point(1, 1)); + expect(Point.min(Point(1, 2), Point(5, 6))).toEqual(Point(1, 2)); + expect(Point.min([3, 4], [1, 1])).toEqual([1, 1]); + expect(Point.min([1, 2], [5, 6])).toEqual([1, 2]); + })); + + describe("::max(a, b)", () => it("returns the minimum of two points", function() { + expect(Point.max(Point(3, 4), Point(1, 1))).toEqual(Point(3, 4)); + expect(Point.max(Point(1, 2), Point(5, 6))).toEqual(Point(5, 6)); + expect(Point.max([3, 4], [1, 1])).toEqual([3, 4]); + expect(Point.max([1, 2], [5, 6])).toEqual([5, 6]); + })); + + describe("::translate(delta)", () => it("returns a new point by adding corresponding coordinates", function() { + expect(Point(1, 1).translate(Point(2, 3))).toEqual(Point(3, 4)); + expect(Point.INFINITY.translate(Point(2, 3))).toEqual(Point.INFINITY); + + expect(Point.ZERO.translate([5, 6])).toEqual([5, 6]); + expect(Point(1, 1).translate([3, 4])).toEqual([4, 5]); + })); + + describe("::traverse(delta)", () => it("returns a new point by traversing given rows and columns", function() { + expect(Point(2, 3).traverse(Point(0, 3))).toEqual(Point(2, 6)); + expect(Point(2, 3).traverse([0, 3])).toEqual([2, 6]); + + expect(Point(1, 3).traverse(Point(4, 2))).toEqual([5, 2]); + expect(Point(1, 3).traverse([5, 4])).toEqual([6, 4]); + })); + + describe("::traversalFrom(other)", () => it("returns a point that other has to traverse to get to given point", function() { + expect(Point(2, 5).traversalFrom(Point(2, 3))).toEqual(Point(0, 2)); + expect(Point(2, 3).traversalFrom(Point(2, 5))).toEqual(Point(0, -2)); + expect(Point(2, 3).traversalFrom(Point(2, 3))).toEqual(Point(0, 0)); + + expect(Point(3, 4).traversalFrom(Point(2, 3))).toEqual(Point(1, 4)); + expect(Point(2, 3).traversalFrom(Point(3, 5))).toEqual(Point(-1, 3)); + + expect(Point(2, 5).traversalFrom([2, 3])).toEqual([0, 2]); + expect(Point(2, 3).traversalFrom([2, 5])).toEqual([0, -2]); + expect(Point(2, 3).traversalFrom([2, 3])).toEqual([0, 0]); + + expect(Point(3, 4).traversalFrom([2, 3])).toEqual([1, 4]); + expect(Point(2, 3).traversalFrom([3, 5])).toEqual([-1, 3]); + })); + + describe("::toArray()", () => it("returns an array of row and column", function() { + expect(Point(1, 3).toArray()).toEqual([1, 3]); + expect(Point.ZERO.toArray()).toEqual([0, 0]); + expect(Point.INFINITY.toArray()).toEqual([Infinity, Infinity]); + })); + + describe("::serialize()", () => it("returns an array of row and column", function() { + expect(Point(1, 3).serialize()).toEqual([1, 3]); + expect(Point.ZERO.serialize()).toEqual([0, 0]); + expect(Point.INFINITY.serialize()).toEqual([Infinity, Infinity]); + })); + + describe("::toString()", () => it("returns string representation of Point", function() { + expect(Point(4, 5).toString()).toBe("(4, 5)"); + expect(Point.ZERO.toString()).toBe("(0, 0)"); + expect(Point.INFINITY.toString()).toBe("(Infinity, Infinity)"); + })); +}); diff --git a/spec/range-spec.coffee b/spec/range-spec.js similarity index 50% rename from spec/range-spec.coffee rename to spec/range-spec.js index ea4caf07a3..dc78411e83 100644 --- a/spec/range-spec.coffee +++ b/spec/range-spec.js @@ -1,42 +1,50 @@ -Range = require '../src/range' +const Range = require('../src/range'); -describe "Range", -> - beforeEach -> - jasmine.addCustomEqualityTester(require("underscore-plus").isEqual) +describe("Range", function() { + beforeEach(() => jasmine.addCustomEqualityTester(require("underscore-plus").isEqual)); - describe "::intersectsWith(other, [exclusive])", -> - intersectsWith = (range1, range2, exclusive) -> - range1 = Range.fromObject(range1) - range2 = Range.fromObject(range2) - range1.intersectsWith(range2, exclusive) + describe("::intersectsWith(other, [exclusive])", function() { + const intersectsWith = function(range1, range2, exclusive) { + range1 = Range.fromObject(range1); + range2 = Range.fromObject(range2); + return range1.intersectsWith(range2, exclusive); + }; - describe "when the exclusive argument is false (the default)", -> - it "returns true if the ranges intersect, exclusive of their endpoints", -> - expect(intersectsWith([[1, 2], [3, 4]], [[1, 0], [1, 1]])).toBe false - expect(intersectsWith([[1, 2], [3, 4]], [[1, 1], [1, 2]])).toBe true - expect(intersectsWith([[1, 2], [3, 4]], [[1, 1], [1, 3]])).toBe true - expect(intersectsWith([[1, 2], [3, 4]], [[3, 4], [4, 5]])).toBe true - expect(intersectsWith([[1, 2], [3, 4]], [[3, 3], [4, 5]])).toBe true - expect(intersectsWith([[1, 2], [3, 4]], [[1, 5], [2, 2]])).toBe true - expect(intersectsWith([[1, 2], [3, 4]], [[3, 5], [4, 4]])).toBe false - expect(intersectsWith([[1, 2], [3, 4]], [[1, 2], [1, 2]], true)).toBe false - expect(intersectsWith([[1, 2], [3, 4]], [[3, 4], [3, 4]], true)).toBe false + describe("when the exclusive argument is false (the default)", () => { + it("returns true if the ranges intersect, exclusive of their endpoints", function() { + expect(intersectsWith([[1, 2], [3, 4]], [[1, 0], [1, 1]])).toBe(false); + expect(intersectsWith([[1, 2], [3, 4]], [[1, 1], [1, 2]])).toBe(true); + expect(intersectsWith([[1, 2], [3, 4]], [[1, 1], [1, 3]])).toBe(true); + expect(intersectsWith([[1, 2], [3, 4]], [[3, 4], [4, 5]])).toBe(true); + expect(intersectsWith([[1, 2], [3, 4]], [[3, 3], [4, 5]])).toBe(true); + expect(intersectsWith([[1, 2], [3, 4]], [[1, 5], [2, 2]])).toBe(true); + expect(intersectsWith([[1, 2], [3, 4]], [[3, 5], [4, 4]])).toBe(false); + expect(intersectsWith([[1, 2], [3, 4]], [[1, 2], [1, 2]], true)).toBe(false); + expect(intersectsWith([[1, 2], [3, 4]], [[3, 4], [3, 4]], true)).toBe(false); + }); + }); - describe "when the exclusive argument is true", -> - it "returns true if the ranges intersect, exclusive of their endpoints", -> - expect(intersectsWith([[1, 2], [3, 4]], [[1, 0], [1, 1]], true)).toBe false - expect(intersectsWith([[1, 2], [3, 4]], [[1, 1], [1, 2]], true)).toBe false - expect(intersectsWith([[1, 2], [3, 4]], [[1, 1], [1, 3]], true)).toBe true - expect(intersectsWith([[1, 2], [3, 4]], [[3, 4], [4, 5]], true)).toBe false - expect(intersectsWith([[1, 2], [3, 4]], [[3, 3], [4, 5]], true)).toBe true - expect(intersectsWith([[1, 2], [3, 4]], [[1, 5], [2, 2]], true)).toBe true - expect(intersectsWith([[1, 2], [3, 4]], [[3, 5], [4, 4]], true)).toBe false - expect(intersectsWith([[1, 2], [3, 4]], [[1, 2], [1, 2]], true)).toBe false - expect(intersectsWith([[1, 2], [3, 4]], [[3, 4], [3, 4]], true)).toBe false + describe("when the exclusive argument is true", () => { + it("returns true if the ranges intersect, exclusive of their endpoints", function() { + expect(intersectsWith([[1, 2], [3, 4]], [[1, 0], [1, 1]], true)).toBe(false); + expect(intersectsWith([[1, 2], [3, 4]], [[1, 1], [1, 2]], true)).toBe(false); + expect(intersectsWith([[1, 2], [3, 4]], [[1, 1], [1, 3]], true)).toBe(true); + expect(intersectsWith([[1, 2], [3, 4]], [[3, 4], [4, 5]], true)).toBe(false); + expect(intersectsWith([[1, 2], [3, 4]], [[3, 3], [4, 5]], true)).toBe(true); + expect(intersectsWith([[1, 2], [3, 4]], [[1, 5], [2, 2]], true)).toBe(true); + expect(intersectsWith([[1, 2], [3, 4]], [[3, 5], [4, 4]], true)).toBe(false); + expect(intersectsWith([[1, 2], [3, 4]], [[1, 2], [1, 2]], true)).toBe(false); + expect(intersectsWith([[1, 2], [3, 4]], [[3, 4], [3, 4]], true)).toBe(false); + }) + }); + }); - describe "::negate()", -> - it "should negate the start and end points", -> - expect(new Range([ 0, 0], [ 0, 0]).negate().toString()).toBe "[(0, 0) - (0, 0)]" - expect(new Range([ 1, 2], [ 3, 4]).negate().toString()).toBe "[(-3, -4) - (-1, -2)]" - expect(new Range([-1, -2], [-3, -4]).negate().toString()).toBe "[(1, 2) - (3, 4)]" - expect(new Range([-1, 2], [ 3, -4]).negate().toString()).toBe "[(-3, 4) - (1, -2)]" + describe("::negate()", () => { + it("should negate the start and end points", function() { + expect(new Range([ 0, 0], [ 0, 0]).negate().toString()).toBe("[(0, 0) - (0, 0)]"); + expect(new Range([ 1, 2], [ 3, 4]).negate().toString()).toBe("[(-3, -4) - (-1, -2)]"); + expect(new Range([-1, -2], [-3, -4]).negate().toString()).toBe("[(1, 2) - (3, 4)]"); + expect(new Range([-1, 2], [ 3, -4]).negate().toString()).toBe("[(-3, 4) - (1, -2)]"); + }) + }); +}); diff --git a/spec/support/jasmine.json b/spec/support/jasmine.json index d86a2b73fc..ac121f793e 100644 --- a/spec/support/jasmine.json +++ b/spec/support/jasmine.json @@ -1,8 +1,7 @@ { "spec_dir": "spec", "spec_files": [ - "**/*-spec.js", - "**/*-spec.coffee" + "**/*-spec.js" ], "helpers": [ "helpers/**/*.js" diff --git a/spec/text-buffer-io-spec.js b/spec/text-buffer-io-spec.js index 8129692c4e..3f2cf8a9ab 100644 --- a/spec/text-buffer-io-spec.js +++ b/spec/text-buffer-io-spec.js @@ -6,17 +6,21 @@ const {Disposable} = require('event-kit') const Point = require('../src/point') const Range = require('../src/range') const TextBuffer = require('../src/text-buffer') -const {TextBuffer: NativeTextBuffer} = require('superstring') +const {TextBuffer: NativeTextBuffer} = require('@pulsar-edit/superstring') const fsAdmin = require('fs-admin') -const pathwatcher = require('pathwatcher') +const pathwatcher = require('@pulsar-edit/pathwatcher') const winattr = require('winattr') process.on('unhandledRejection', console.error) +async function wait (ms) { + return new Promise(r => setTimeout(r, ms)); +} + describe('TextBuffer IO', () => { let buffer, buffer2 - afterEach(() => { + afterEach(async () => { if (buffer) buffer.destroy() if (buffer2) buffer2.destroy() @@ -27,6 +31,10 @@ describe('TextBuffer IO', () => { } pathwatcher.closeAllWatchers() } + // `await` briefly to allow the file watcher to clean up. This is a + // `pathwatcher` requirement that we can fix by updating its API — but + // that's a can of worms we don't want to open yet. + await wait(10); }) describe('.load', () => { @@ -965,6 +973,9 @@ describe('TextBuffer IO', () => { fs.writeFileSync(buffer.getPath(), 'abcde') const subscription = buffer.file.onDidChange(() => { + // `text-buffer` consumes this through a `debounce` helper. We don't, + // so we should manually ensure this handler runs only once. + if (subscription.disposed) return subscription.dispose() setTimeout(() => { expect(buffer.getText()).toBe('abcde') diff --git a/spec/text-buffer-spec.coffee b/spec/text-buffer-spec.coffee deleted file mode 100644 index d87c2b3134..0000000000 --- a/spec/text-buffer-spec.coffee +++ /dev/null @@ -1,2994 +0,0 @@ -fs = require 'fs-plus' -{join} = require 'path' -temp = require 'temp' -{File} = require 'pathwatcher' -Random = require 'random-seed' -Point = require '../src/point' -Range = require '../src/range' -DisplayLayer = require '../src/display-layer' -DefaultHistoryProvider = require '../src/default-history-provider' -TextBuffer = require '../src/text-buffer' -SampleText = fs.readFileSync(join(__dirname, 'fixtures', 'sample.js'), 'utf8') -{buildRandomLines, getRandomBufferRange} = require './helpers/random' -NullLanguageMode = require '../src/null-language-mode' - -describe "TextBuffer", -> - buffer = null - - beforeEach -> - temp.track() - jasmine.addCustomEqualityTester(require("underscore-plus").isEqual) - # When running specs in Atom, setTimeout is spied on by default. - jasmine.useRealClock?() - - afterEach -> - buffer?.destroy() - buffer = null - - describe "construction", -> - it "can be constructed empty", -> - buffer = new TextBuffer - expect(buffer.getLineCount()).toBe 1 - expect(buffer.getText()).toBe '' - expect(buffer.lineForRow(0)).toBe '' - expect(buffer.lineEndingForRow(0)).toBe '' - - it "can be constructed with initial text containing no trailing newline", -> - text = "hello\nworld\r\nhow are you doing?\r\nlast" - buffer = new TextBuffer(text) - expect(buffer.getLineCount()).toBe 4 - expect(buffer.getText()).toBe text - expect(buffer.lineForRow(0)).toBe 'hello' - expect(buffer.lineEndingForRow(0)).toBe '\n' - expect(buffer.lineForRow(1)).toBe 'world' - expect(buffer.lineEndingForRow(1)).toBe '\r\n' - expect(buffer.lineForRow(2)).toBe 'how are you doing?' - expect(buffer.lineEndingForRow(2)).toBe '\r\n' - expect(buffer.lineForRow(3)).toBe 'last' - expect(buffer.lineEndingForRow(3)).toBe '' - - it "can be constructed with initial text containing a trailing newline", -> - text = "first\n" - buffer = new TextBuffer(text) - expect(buffer.getLineCount()).toBe 2 - expect(buffer.getText()).toBe text - expect(buffer.lineForRow(0)).toBe 'first' - expect(buffer.lineEndingForRow(0)).toBe '\n' - expect(buffer.lineForRow(1)).toBe '' - expect(buffer.lineEndingForRow(1)).toBe '' - - it "automatically assigns a unique identifier to new buffers", -> - bufferIds = [0..16].map(-> new TextBuffer().getId()) - uniqueBufferIds = new Set(bufferIds) - - expect(uniqueBufferIds.size).toBe(bufferIds.length) - - describe "::destroy()", -> - it "clears the buffer's state", (done) -> - filePath = temp.openSync('atom').path - buffer = new TextBuffer() - buffer.setPath(filePath) - buffer.append("a") - buffer.append("b") - buffer.destroy() - - expect(buffer.getText()).toBe('') - buffer.undo() - expect(buffer.getText()).toBe('') - buffer.save().catch (error) -> - expect(error.message).toMatch(/Can't save destroyed buffer/) - done() - - describe "::setTextInRange(range, text)", -> - beforeEach -> - buffer = new TextBuffer("hello\nworld\r\nhow are you doing?") - - it "can replace text on a single line with a standard newline", -> - buffer.setTextInRange([[0, 2], [0, 4]], "y y") - expect(buffer.getText()).toEqual "hey yo\nworld\r\nhow are you doing?" - - it "can replace text on a single line with a carriage-return/newline", -> - buffer.setTextInRange([[1, 3], [1, 5]], "ms") - expect(buffer.getText()).toEqual "hello\nworms\r\nhow are you doing?" - - it "can replace text in a region spanning multiple lines, ending on the last line", -> - buffer.setTextInRange([[0, 2], [2, 3]], "y there\r\ncat\nwhat", normalizeLineEndings: false) - expect(buffer.getText()).toEqual "hey there\r\ncat\nwhat are you doing?" - - it "can replace text in a region spanning multiple lines, ending with a carriage-return/newline", -> - buffer.setTextInRange([[0, 2], [1, 3]], "y\nyou're o", normalizeLineEndings: false) - expect(buffer.getText()).toEqual "hey\nyou're old\r\nhow are you doing?" - - describe "after a change", -> - it "notifies, in order: the language mode, display layers, and display layer ::onDidChange observers with the relevant details", -> - buffer = new TextBuffer("hello\nworld\r\nhow are you doing?") - - events = [] - languageMode = { - bufferDidChange: (e) -> events.push({source: 'language-mode', event: e}), - bufferDidFinishTransaction: ->, - onDidChangeHighlighting: -> {dispose: ->} - } - displayLayer1 = buffer.addDisplayLayer() - displayLayer2 = buffer.addDisplayLayer() - spyOn(displayLayer1, 'bufferDidChange').and.callFake (e) -> - events.push({source: 'display-layer-1', event: e}) - DisplayLayer.prototype.bufferDidChange.call(displayLayer1, e) - spyOn(displayLayer2, 'bufferDidChange').and.callFake (e) -> - events.push({source: 'display-layer-2', event: e}) - DisplayLayer.prototype.bufferDidChange.call(displayLayer2, e) - buffer.setLanguageMode(languageMode) - buffer.onDidChange (e) -> events.push({source: 'buffer', event: JSON.parse(JSON.stringify(e))}) - displayLayer1.onDidChange (e) -> events.push({source: 'display-layer-event', event: e}) - - buffer.transact -> - buffer.setTextInRange([[0, 2], [2, 3]], "y there\r\ncat\nwhat", normalizeLineEndings: false) - buffer.setTextInRange([[1, 1], [1, 2]], "abc", normalizeLineEndings: false) - - changeEvent1 = { - oldRange: [[0, 2], [2, 3]], newRange: [[0, 2], [2, 4]] - oldText: "llo\nworld\r\nhow", newText: "y there\r\ncat\nwhat", - } - changeEvent2 = { - oldRange: [[1, 1], [1, 2]], newRange: [[1, 1], [1, 4]] - oldText: "a", newText: "abc", - } - expect(events).toEqual [ - {source: 'language-mode', event: changeEvent1}, - {source: 'display-layer-1', event: changeEvent1}, - {source: 'display-layer-2', event: changeEvent1}, - - {source: 'language-mode', event: changeEvent2}, - {source: 'display-layer-1', event: changeEvent2}, - {source: 'display-layer-2', event: changeEvent2}, - - { - source: 'buffer', - event: { - oldRange: Range(Point(0, 2), Point(2, 3)), - newRange: Range(Point(0, 2), Point(2, 4)), - changes: [ - { - oldRange: Range(Point(0, 2), Point(2, 3)), - newRange: Range(Point(0, 2), Point(2, 4)), - oldText: "llo\nworld\r\nhow", - newText: "y there\r\ncabct\nwhat" - } - ] - } - }, - { - source: 'display-layer-event', - event: [{ - oldRange: Range(Point(0, 0), Point(3, 0)), - newRange: Range(Point(0, 0), Point(3, 0)) - }] - } - ] - - it "returns the newRange of the change", -> - expect(buffer.setTextInRange([[0, 2], [2, 3]], "y there\r\ncat\nwhat"), normalizeLineEndings: false).toEqual [[0, 2], [2, 4]] - - it "clips the given range", -> - buffer.setTextInRange([[-1, -1], [0, 1]], "y") - buffer.setTextInRange([[0, 10], [0, 100]], "w") - expect(buffer.lineForRow(0)).toBe "yellow" - - it "preserves the line endings of existing lines", -> - buffer.setTextInRange([[0, 1], [0, 2]], 'o') - expect(buffer.lineEndingForRow(0)).toBe '\n' - buffer.setTextInRange([[1, 1], [1, 3]], 'i') - expect(buffer.lineEndingForRow(1)).toBe '\r\n' - - it "freezes change event ranges", -> - changedOldRange = null - changedNewRange = null - buffer.onDidChange ({oldRange, newRange}) -> - oldRange.start = Point(0, 3) - oldRange.start.row = 1 - newRange.start = Point(4, 4) - newRange.end.row = 2 - changedOldRange = oldRange - changedNewRange = newRange - - buffer.setTextInRange(Range(Point(0, 2), Point(0, 4)), "y y") - - expect(changedOldRange).toEqual([[0, 2], [0, 4]]) - expect(changedNewRange).toEqual([[0, 2], [0, 5]]) - - describe "when the undo option is 'skip'", -> - it "replaces the contents of the buffer with the given text", -> - buffer.setTextInRange([[0, 0], [0, 1]], "y") - buffer.setTextInRange([[0, 10], [0, 100]], "w", {undo: 'skip'}) - expect(buffer.lineForRow(0)).toBe "yellow" - - expect(buffer.undo()).toBe true - expect(buffer.lineForRow(0)).toBe "hello" - - it "still emits marker change events (regression)", -> - markerLayer = buffer.addMarkerLayer() - marker = markerLayer.markRange([[0, 0], [0, 3]]) - - markerLayerUpdateEventsCount = 0 - markerChangeEvents = [] - markerLayer.onDidUpdate -> markerLayerUpdateEventsCount++ - marker.onDidChange (event) -> markerChangeEvents.push(event) - - buffer.setTextInRange([[0, 0], [0, 1]], '', {undo: 'skip'}) - expect(markerLayerUpdateEventsCount).toBe(1) - expect(markerChangeEvents).toEqual([{ - wasValid: true, isValid: true, - hadTail: true, hasTail: true, - oldProperties: {}, newProperties: {}, - oldHeadPosition: Point(0, 3), newHeadPosition: Point(0, 2), - oldTailPosition: Point(0, 0), newTailPosition: Point(0, 0), - textChanged: true - }]) - markerChangeEvents.length = 0 - - buffer.transact -> - buffer.setTextInRange([[0, 0], [0, 1]], '', {undo: 'skip'}) - expect(markerLayerUpdateEventsCount).toBe(2) - expect(markerChangeEvents).toEqual([{ - wasValid: true, isValid: true, - hadTail: true, hasTail: true, - oldProperties: {}, newProperties: {}, - oldHeadPosition: Point(0, 2), newHeadPosition: Point(0, 1), - oldTailPosition: Point(0, 0), newTailPosition: Point(0, 0), - textChanged: true - }]) - - it "still emits text change events (regression)", (done) -> - didChangeEvents = [] - buffer.onDidChange (event) -> didChangeEvents.push(event) - - buffer.onDidStopChanging ({changes}) -> - assertChangesEqual(changes, [{ - oldRange: [[0, 0], [0, 1]], - newRange: [[0, 0], [0, 1]], - oldText: 'h', - newText: 'z' - }]) - done() - - buffer.setTextInRange([[0, 0], [0, 1]], 'y', {undo: 'skip'}) - expect(didChangeEvents.length).toBe(1) - assertChangesEqual(didChangeEvents[0].changes, [{ - oldRange: [[0, 0], [0, 1]], - newRange: [[0, 0], [0, 1]], - oldText: 'h', - newText: 'y' - }]) - - buffer.transact -> buffer.setTextInRange([[0, 0], [0, 1]], 'z', {undo: 'skip'}) - expect(didChangeEvents.length).toBe(2) - assertChangesEqual(didChangeEvents[1].changes, [{ - oldRange: [[0, 0], [0, 1]], - newRange: [[0, 0], [0, 1]], - oldText: 'y', - newText: 'z' - }]) - - describe "when the normalizeLineEndings argument is true (the default)", -> - describe "when the range's start row has a line ending", -> - it "normalizes inserted line endings to match the line ending of the range's start row", -> - changeEvents = [] - buffer.onDidChange (e) -> changeEvents.push(e) - - expect(buffer.lineEndingForRow(0)).toBe '\n' - buffer.setTextInRange([[0, 2], [0, 5]], "y\r\nthere\r\ncrazy") - expect(buffer.lineEndingForRow(0)).toBe '\n' - expect(buffer.lineEndingForRow(1)).toBe '\n' - expect(buffer.lineEndingForRow(2)).toBe '\n' - expect(changeEvents[0].newText).toBe "y\nthere\ncrazy" - - expect(buffer.lineEndingForRow(3)).toBe '\r\n' - buffer.setTextInRange([[3, 3], [4, Infinity]], "ms\ndo you\r\nlike\ndirt") - expect(buffer.lineEndingForRow(3)).toBe '\r\n' - expect(buffer.lineEndingForRow(4)).toBe '\r\n' - expect(buffer.lineEndingForRow(5)).toBe '\r\n' - expect(buffer.lineEndingForRow(6)).toBe '' - expect(changeEvents[1].newText).toBe "ms\r\ndo you\r\nlike\r\ndirt" - - buffer.setTextInRange([[5, 1], [5, 3]], '\r') - expect(changeEvents[2].changes).toEqual([{ - oldRange: [[5, 1], [5, 3]], - newRange: [[5, 1], [6, 0]], - oldText: 'ik', - newText: '\r\n' - }]) - - buffer.undo() - expect(changeEvents[3].changes).toEqual([{ - oldRange: [[5, 1], [6, 0]], - newRange: [[5, 1], [5, 3]], - oldText: '\r\n', - newText: 'ik' - }]) - - buffer.redo() - expect(changeEvents[4].changes).toEqual([{ - oldRange: [[5, 1], [5, 3]], - newRange: [[5, 1], [6, 0]], - oldText: 'ik', - newText: '\r\n' - }]) - - describe "when the range's start row has no line ending (because it's the last line of the buffer)", -> - describe "when the buffer contains no newlines", -> - it "honors the newlines in the inserted text", -> - buffer = new TextBuffer("hello") - buffer.setTextInRange([[0, 2], [0, Infinity]], "hey\r\nthere\nworld") - expect(buffer.lineEndingForRow(0)).toBe '\r\n' - expect(buffer.lineEndingForRow(1)).toBe '\n' - expect(buffer.lineEndingForRow(2)).toBe '' - - describe "when the buffer contains newlines", -> - it "normalizes inserted line endings to match the line ending of the penultimate row", -> - expect(buffer.lineEndingForRow(1)).toBe '\r\n' - buffer.setTextInRange([[2, 0], [2, Infinity]], "what\ndo\r\nyou\nwant?") - expect(buffer.lineEndingForRow(2)).toBe '\r\n' - expect(buffer.lineEndingForRow(3)).toBe '\r\n' - expect(buffer.lineEndingForRow(4)).toBe '\r\n' - expect(buffer.lineEndingForRow(5)).toBe '' - - describe "when the normalizeLineEndings argument is false", -> - it "honors the newlines in the inserted text", -> - buffer.setTextInRange([[1, 0], [1, 5]], "moon\norbiting\r\nhappily\nthere", {normalizeLineEndings: false}) - expect(buffer.lineEndingForRow(1)).toBe '\n' - expect(buffer.lineEndingForRow(2)).toBe '\r\n' - expect(buffer.lineEndingForRow(3)).toBe '\n' - expect(buffer.lineEndingForRow(4)).toBe '\r\n' - expect(buffer.lineEndingForRow(5)).toBe '' - - describe "::setText(text)", -> - it "replaces the contents of the buffer with the given text", -> - buffer = new TextBuffer("hello\nworld\r\nyou are cool") - buffer.setText("goodnight\r\nmoon\nit's been good") - expect(buffer.getText()).toBe "goodnight\r\nmoon\nit's been good" - buffer.undo() - expect(buffer.getText()).toBe "hello\nworld\r\nyou are cool" - - describe "::insert(position, text, normalizeNewlinesn)", -> - it "inserts text at the given position", -> - buffer = new TextBuffer("hello world") - buffer.insert([0, 5], " there") - expect(buffer.getText()).toBe "hello there world" - - it "honors the normalizeNewlines option", -> - buffer = new TextBuffer("hello\nworld") - buffer.insert([0, 5], "\r\nthere\r\nlittle", normalizeLineEndings: false) - expect(buffer.getText()).toBe "hello\r\nthere\r\nlittle\nworld" - - describe "::append(text, normalizeNewlines)", -> - it "appends text to the end of the buffer", -> - buffer = new TextBuffer("hello world") - buffer.append(", how are you?") - expect(buffer.getText()).toBe "hello world, how are you?" - - it "honors the normalizeNewlines option", -> - buffer = new TextBuffer("hello\nworld") - buffer.append("\r\nhow\r\nare\nyou?", normalizeLineEndings: false) - expect(buffer.getText()).toBe "hello\nworld\r\nhow\r\nare\nyou?" - - describe "::delete(range)", -> - it "deletes text in the given range", -> - buffer = new TextBuffer("hello world") - buffer.delete([[0, 5], [0, 11]]) - expect(buffer.getText()).toBe "hello" - - describe "::deleteRows(startRow, endRow)", -> - beforeEach -> - buffer = new TextBuffer("first\nsecond\nthird\nlast") - - describe "when the endRow is less than the last row of the buffer", -> - it "deletes the specified rows", -> - buffer.deleteRows(1, 2) - expect(buffer.getText()).toBe "first\nlast" - buffer.deleteRows(0, 0) - expect(buffer.getText()).toBe "last" - - describe "when the endRow is the last row of the buffer", -> - it "deletes the specified rows", -> - buffer.deleteRows(2, 3) - expect(buffer.getText()).toBe "first\nsecond" - buffer.deleteRows(0, 1) - expect(buffer.getText()).toBe "" - - it "clips the given row range", -> - buffer.deleteRows(-1, 0) - expect(buffer.getText()).toBe "second\nthird\nlast" - buffer.deleteRows(1, 5) - expect(buffer.getText()).toBe "second" - - buffer.deleteRows(-2, -1) - expect(buffer.getText()).toBe "second" - buffer.deleteRows(1, 2) - expect(buffer.getText()).toBe "second" - - it "handles out of order row ranges", -> - buffer.deleteRows(2, 1) - expect(buffer.getText()).toBe "first\nlast" - - describe "::getText()", -> - it "returns the contents of the buffer as a single string", -> - buffer = new TextBuffer("hello\nworld\r\nhow are you?") - expect(buffer.getText()).toBe "hello\nworld\r\nhow are you?" - buffer.setTextInRange([[1, 0], [1, 5]], "mom") - expect(buffer.getText()).toBe "hello\nmom\r\nhow are you?" - - describe "::undo() and ::redo()", -> - beforeEach -> - buffer = new TextBuffer(text: "hello\nworld\r\nhow are you doing?") - - it "undoes and redoes multiple changes", -> - buffer.setTextInRange([[0, 5], [0, 5]], " there") - buffer.setTextInRange([[1, 0], [1, 5]], "friend") - expect(buffer.getText()).toBe "hello there\nfriend\r\nhow are you doing?" - - buffer.undo() - expect(buffer.getText()).toBe "hello there\nworld\r\nhow are you doing?" - - buffer.undo() - expect(buffer.getText()).toBe "hello\nworld\r\nhow are you doing?" - - buffer.undo() - expect(buffer.getText()).toBe "hello\nworld\r\nhow are you doing?" - - buffer.redo() - expect(buffer.getText()).toBe "hello there\nworld\r\nhow are you doing?" - - buffer.undo() - expect(buffer.getText()).toBe "hello\nworld\r\nhow are you doing?" - - buffer.redo() - buffer.redo() - expect(buffer.getText()).toBe "hello there\nfriend\r\nhow are you doing?" - - buffer.redo() - expect(buffer.getText()).toBe "hello there\nfriend\r\nhow are you doing?" - - it "clears the redo stack upon a fresh change", -> - buffer.setTextInRange([[0, 5], [0, 5]], " there") - buffer.setTextInRange([[1, 0], [1, 5]], "friend") - expect(buffer.getText()).toBe "hello there\nfriend\r\nhow are you doing?" - - buffer.undo() - expect(buffer.getText()).toBe "hello there\nworld\r\nhow are you doing?" - - buffer.setTextInRange([[1, 3], [1, 5]], "m") - expect(buffer.getText()).toBe "hello there\nworm\r\nhow are you doing?" - - buffer.redo() - expect(buffer.getText()).toBe "hello there\nworm\r\nhow are you doing?" - - buffer.undo() - expect(buffer.getText()).toBe "hello there\nworld\r\nhow are you doing?" - - buffer.undo() - expect(buffer.getText()).toBe "hello\nworld\r\nhow are you doing?" - - it "does not allow the undo stack to grow without bound", -> - buffer = new TextBuffer(maxUndoEntries: 12) - - # Each transaction is treated as a single undo entry. We can undo up - # to 12 of them. - buffer.setText("") - buffer.clearUndoStack() - for i in [0...13] - buffer.transact -> - buffer.append(String(i)) - buffer.append("\n") - expect(buffer.getLineCount()).toBe 14 - - undoCount = 0 - undoCount++ while buffer.undo() - expect(undoCount).toBe 12 - expect(buffer.getText()).toBe '0\n' - - describe "::createMarkerSnapshot", -> - markerLayers = null - - beforeEach -> - buffer = new TextBuffer - - markerLayers = [ - buffer.addMarkerLayer(maintainHistory: true, role: "selections") - buffer.addMarkerLayer(maintainHistory: true) - buffer.addMarkerLayer(maintainHistory: true, role: "selections") - buffer.addMarkerLayer(maintainHistory: true) - ] - - describe "when selectionsMarkerLayer is not passed", -> - it "takes a snapshot of all markerLayers", -> - snapshot = buffer.createMarkerSnapshot() - markerLayerIdsInSnapshot = Object.keys(snapshot) - expect(markerLayerIdsInSnapshot.length).toBe(4) - expect(markerLayers[0].id in markerLayerIdsInSnapshot).toBe(true) - expect(markerLayers[1].id in markerLayerIdsInSnapshot).toBe(true) - expect(markerLayers[2].id in markerLayerIdsInSnapshot).toBe(true) - expect(markerLayers[3].id in markerLayerIdsInSnapshot).toBe(true) - - describe "when selectionsMarkerLayer is passed", -> - it "skips snapshotting of other 'selection' role marker layers", -> - snapshot = buffer.createMarkerSnapshot(markerLayers[0]) - markerLayerIdsInSnapshot = Object.keys(snapshot) - expect(markerLayerIdsInSnapshot.length).toBe(3) - expect(markerLayers[0].id in markerLayerIdsInSnapshot).toBe(true) - expect(markerLayers[1].id in markerLayerIdsInSnapshot).toBe(true) - expect(markerLayers[2].id in markerLayerIdsInSnapshot).toBe(false) - expect(markerLayers[3].id in markerLayerIdsInSnapshot).toBe(true) - - snapshot = buffer.createMarkerSnapshot(markerLayers[2]) - markerLayerIdsInSnapshot = Object.keys(snapshot) - expect(markerLayerIdsInSnapshot.length).toBe(3) - expect(markerLayers[0].id in markerLayerIdsInSnapshot).toBe(false) - expect(markerLayers[1].id in markerLayerIdsInSnapshot).toBe(true) - expect(markerLayers[2].id in markerLayerIdsInSnapshot).toBe(true) - expect(markerLayers[3].id in markerLayerIdsInSnapshot).toBe(true) - - describe "selective snapshotting and restoration on transact/undo/redo for selections marker layer", -> - [markerLayers, marker0, marker1, marker2, textUndo, textRedo, rangesBefore, rangesAfter] = [] - ensureMarkerLayer = (markerLayer, range) -> - markers = markerLayer.findMarkers({}) - expect(markers.length).toBe(1) - expect(markers[0].getRange()).toEqual(range) - - getFirstMarker = (markerLayer) -> - markerLayer.findMarkers({})[0] - - beforeEach -> - buffer = new TextBuffer(text: "00000000\n11111111\n22222222\n33333333\n") - - markerLayers = [ - buffer.addMarkerLayer(maintainHistory: true, role: "selections") - buffer.addMarkerLayer(maintainHistory: true, role: "selections") - buffer.addMarkerLayer(maintainHistory: true, role: "selections") - ] - - textUndo = "00000000\n11111111\n22222222\n33333333\n" - textRedo = "00000000\n11111111\n22222222\n33333333\n44444444\n" - - rangesBefore = [ - [[0, 1], [0, 1]] - [[0, 2], [0, 2]] - [[0, 3], [0, 3]] - ] - rangesAfter = [ - [[2, 1], [2, 1]] - [[2, 2], [2, 2]] - [[2, 3], [2, 3]] - ] - - marker0 = markerLayers[0].markRange(rangesBefore[0]) - marker1 = markerLayers[1].markRange(rangesBefore[1]) - marker2 = markerLayers[2].markRange(rangesBefore[2]) - - it "restores a snapshot from other selections marker layers on undo/redo", -> - # Snapshot is taken for markerLayers[0] only, markerLayer[1] and markerLayer[2] are skipped - buffer.transact {selectionsMarkerLayer: markerLayers[0]}, -> - buffer.append("44444444\n") - marker0.setRange(rangesAfter[0]) - marker1.setRange(rangesAfter[1]) - marker2.setRange(rangesAfter[2]) - - buffer.undo({selectionsMarkerLayer: markerLayers[0]}) - expect(buffer.getText()).toBe(textUndo) - - ensureMarkerLayer(markerLayers[0], rangesBefore[0]) - ensureMarkerLayer(markerLayers[1], rangesAfter[1]) - ensureMarkerLayer(markerLayers[2], rangesAfter[2]) - expect(getFirstMarker(markerLayers[0])).toBe(marker0) - expect(getFirstMarker(markerLayers[1])).toBe(marker1) - expect(getFirstMarker(markerLayers[2])).toBe(marker2) - - buffer.redo({selectionsMarkerLayer: markerLayers[0]}) - expect(buffer.getText()).toBe(textRedo) - - ensureMarkerLayer(markerLayers[0], rangesAfter[0]) - ensureMarkerLayer(markerLayers[1], rangesAfter[1]) - ensureMarkerLayer(markerLayers[2], rangesAfter[2]) - expect(getFirstMarker(markerLayers[0])).toBe(marker0) - expect(getFirstMarker(markerLayers[1])).toBe(marker1) - expect(getFirstMarker(markerLayers[2])).toBe(marker2) - - buffer.undo({selectionsMarkerLayer: markerLayers[1]}) - expect(buffer.getText()).toBe(textUndo) - - ensureMarkerLayer(markerLayers[0], rangesAfter[0]) - ensureMarkerLayer(markerLayers[1], rangesBefore[0]) - ensureMarkerLayer(markerLayers[2], rangesAfter[2]) - expect(getFirstMarker(markerLayers[0])).toBe(marker0) - expect(getFirstMarker(markerLayers[1])).not.toBe(marker1) - expect(getFirstMarker(markerLayers[2])).toBe(marker2) - expect(marker1.isDestroyed()).toBe(true) - - buffer.redo({selectionsMarkerLayer: markerLayers[2]}) - expect(buffer.getText()).toBe(textRedo) - - ensureMarkerLayer(markerLayers[0], rangesAfter[0]) - ensureMarkerLayer(markerLayers[1], rangesBefore[0]) - ensureMarkerLayer(markerLayers[2], rangesAfter[0]) - expect(getFirstMarker(markerLayers[0])).toBe(marker0) - expect(getFirstMarker(markerLayers[1])).not.toBe(marker1) - expect(getFirstMarker(markerLayers[2])).not.toBe(marker2) - expect(marker1.isDestroyed()).toBe(true) - expect(marker2.isDestroyed()).toBe(true) - - buffer.undo({selectionsMarkerLayer: markerLayers[2]}) - expect(buffer.getText()).toBe(textUndo) - - ensureMarkerLayer(markerLayers[0], rangesAfter[0]) - ensureMarkerLayer(markerLayers[1], rangesBefore[0]) - ensureMarkerLayer(markerLayers[2], rangesBefore[0]) - expect(getFirstMarker(markerLayers[0])).toBe(marker0) - expect(getFirstMarker(markerLayers[1])).not.toBe(marker1) - expect(getFirstMarker(markerLayers[2])).not.toBe(marker2) - expect(marker1.isDestroyed()).toBe(true) - expect(marker2.isDestroyed()).toBe(true) - - it "can restore a snapshot taken at a destroyed selections marker layer given selectionsMarkerLayer", -> - buffer.transact {selectionsMarkerLayer: markerLayers[1]}, -> - buffer.append("44444444\n") - marker0.setRange(rangesAfter[0]) - marker1.setRange(rangesAfter[1]) - marker2.setRange(rangesAfter[2]) - - markerLayers[1].destroy() - expect(buffer.getMarkerLayer(markerLayers[0].id)).toBeTruthy() - expect(buffer.getMarkerLayer(markerLayers[1].id)).toBeFalsy() - expect(buffer.getMarkerLayer(markerLayers[2].id)).toBeTruthy() - expect(marker0.isDestroyed()).toBe(false) - expect(marker1.isDestroyed()).toBe(true) - expect(marker2.isDestroyed()).toBe(false) - - buffer.undo({selectionsMarkerLayer: markerLayers[0]}) - expect(buffer.getText()).toBe(textUndo) - - ensureMarkerLayer(markerLayers[0], rangesBefore[1]) - ensureMarkerLayer(markerLayers[2], rangesAfter[2]) - expect(marker0.isDestroyed()).toBe(true) - expect(marker2.isDestroyed()).toBe(false) - - buffer.redo({selectionsMarkerLayer: markerLayers[0]}) - expect(buffer.getText()).toBe(textRedo) - ensureMarkerLayer(markerLayers[0], rangesAfter[1]) - ensureMarkerLayer(markerLayers[2], rangesAfter[2]) - - markerLayers[3] = markerLayers[2].copy() - ensureMarkerLayer(markerLayers[3], rangesAfter[2]) - markerLayers[0].destroy() - markerLayers[2].destroy() - expect(buffer.getMarkerLayer(markerLayers[0].id)).toBeFalsy() - expect(buffer.getMarkerLayer(markerLayers[1].id)).toBeFalsy() - expect(buffer.getMarkerLayer(markerLayers[2].id)).toBeFalsy() - expect(buffer.getMarkerLayer(markerLayers[3].id)).toBeTruthy() - - buffer.undo({selectionsMarkerLayer: markerLayers[3]}) - expect(buffer.getText()).toBe(textUndo) - ensureMarkerLayer(markerLayers[3], rangesBefore[1]) - buffer.redo({selectionsMarkerLayer: markerLayers[3]}) - expect(buffer.getText()).toBe(textRedo) - ensureMarkerLayer(markerLayers[3], rangesAfter[1]) - - it "falls back to normal behavior when the snaphot includes multiple layerSnapshots of selections marker layers", -> - # Transact without selectionsMarkerLayer. - # Taken snapshot includes layerSnapshot of markerLayer[0], markerLayer[1] and markerLayer[2] - buffer.transact -> - buffer.append("44444444\n") - marker0.setRange(rangesAfter[0]) - marker1.setRange(rangesAfter[1]) - marker2.setRange(rangesAfter[2]) - - buffer.undo({selectionsMarkerLayer: markerLayers[0]}) - expect(buffer.getText()).toBe(textUndo) - - ensureMarkerLayer(markerLayers[0], rangesBefore[0]) - ensureMarkerLayer(markerLayers[1], rangesBefore[1]) - ensureMarkerLayer(markerLayers[2], rangesBefore[2]) - expect(getFirstMarker(markerLayers[0])).toBe(marker0) - expect(getFirstMarker(markerLayers[1])).toBe(marker1) - expect(getFirstMarker(markerLayers[2])).toBe(marker2) - - buffer.redo({selectionsMarkerLayer: markerLayers[0]}) - expect(buffer.getText()).toBe(textRedo) - - ensureMarkerLayer(markerLayers[0], rangesAfter[0]) - ensureMarkerLayer(markerLayers[1], rangesAfter[1]) - ensureMarkerLayer(markerLayers[2], rangesAfter[2]) - expect(getFirstMarker(markerLayers[0])).toBe(marker0) - expect(getFirstMarker(markerLayers[1])).toBe(marker1) - expect(getFirstMarker(markerLayers[2])).toBe(marker2) - - describe "selections marker layer's selective snapshotting on createCheckpoint, groupChangesSinceCheckpoint", -> - it "skips snapshotting of other marker layers with the same role as the selectionsMarkerLayer", -> - eventHandler = jasmine.createSpy('eventHandler') - - args = [] - spyOn(buffer, 'createMarkerSnapshot').and.callFake (arg) -> args.push(arg) - - checkpoint1 = buffer.createCheckpoint({selectionsMarkerLayer: markerLayers[0]}) - checkpoint2 = buffer.createCheckpoint() - checkpoint3 = buffer.createCheckpoint({selectionsMarkerLayer: markerLayers[2]}) - checkpoint4 = buffer.createCheckpoint({selectionsMarkerLayer: markerLayers[1]}) - expect(args).toEqual([ - markerLayers[0], - undefined, - markerLayers[2], - markerLayers[1], - ]) - - buffer.groupChangesSinceCheckpoint(checkpoint4, {selectionsMarkerLayer: markerLayers[0]}) - buffer.groupChangesSinceCheckpoint(checkpoint3, {selectionsMarkerLayer: markerLayers[2]}) - buffer.groupChangesSinceCheckpoint(checkpoint2) - buffer.groupChangesSinceCheckpoint(checkpoint1, {selectionsMarkerLayer: markerLayers[1]}) - expect(args).toEqual([ - markerLayers[0], - undefined, - markerLayers[2], - markerLayers[1], - - markerLayers[0], - markerLayers[2], - undefined, - markerLayers[1], - ]) - - describe "transactions", -> - now = null - - beforeEach -> - now = 0 - spyOn(Date, 'now').and.callFake -> now - - buffer = new TextBuffer(text: "hello\nworld\r\nhow are you doing?") - buffer.setTextInRange([[1, 3], [1, 5]], 'ms') - - describe "::transact(groupingInterval, fn)", -> - it "groups all operations in the given function in a single transaction", -> - buffer.transact -> - buffer.setTextInRange([[0, 2], [0, 5]], "y") - buffer.transact -> - buffer.setTextInRange([[2, 13], [2, 14]], "igg") - - expect(buffer.getText()).toBe "hey\nworms\r\nhow are you digging?" - buffer.undo() - expect(buffer.getText()).toBe "hello\nworms\r\nhow are you doing?" - buffer.undo() - expect(buffer.getText()).toBe "hello\nworld\r\nhow are you doing?" - - it "halts execution of the function if the transaction is aborted", -> - innerContinued = false - outerContinued = false - - buffer.transact -> - buffer.setTextInRange([[0, 2], [0, 5]], "y") - buffer.transact -> - buffer.setTextInRange([[2, 13], [2, 14]], "igg") - buffer.abortTransaction() - innerContinued = true - outerContinued = true - - expect(innerContinued).toBe false - expect(outerContinued).toBe true - expect(buffer.getText()).toBe "hey\nworms\r\nhow are you doing?" - - it "groups all operations performed within the given function into a single undo/redo operation", -> - buffer.transact -> - buffer.setTextInRange([[0, 2], [0, 5]], "y") - buffer.setTextInRange([[2, 13], [2, 14]], "igg") - - expect(buffer.getText()).toBe "hey\nworms\r\nhow are you digging?" - - # subsequent changes are not included in the transaction - buffer.setTextInRange([[1, 0], [1, 0]], "little ") - buffer.undo() - expect(buffer.getText()).toBe "hey\nworms\r\nhow are you digging?" - - # this should undo all changes in the transaction - buffer.undo() - expect(buffer.getText()).toBe "hello\nworms\r\nhow are you doing?" - - # previous changes are not included in the transaction - buffer.undo() - expect(buffer.getText()).toBe "hello\nworld\r\nhow are you doing?" - - buffer.redo() - expect(buffer.getText()).toBe "hello\nworms\r\nhow are you doing?" - - # this should redo all changes in the transaction - buffer.redo() - expect(buffer.getText()).toBe "hey\nworms\r\nhow are you digging?" - - # this should redo the change following the transaction - buffer.redo() - expect(buffer.getText()).toBe "hey\nlittle worms\r\nhow are you digging?" - - it "does not push the transaction to the undo stack if it is empty", -> - buffer.transact -> - buffer.undo() - expect(buffer.getText()).toBe "hello\nworld\r\nhow are you doing?" - - buffer.redo() - buffer.transact -> buffer.abortTransaction() - buffer.undo() - expect(buffer.getText()).toBe "hello\nworld\r\nhow are you doing?" - - it "halts execution undoes all operations since the beginning of the transaction if ::abortTransaction() is called", -> - continuedPastAbort = false - buffer.transact -> - buffer.setTextInRange([[0, 2], [0, 5]], "y") - buffer.setTextInRange([[2, 13], [2, 14]], "igg") - buffer.abortTransaction() - continuedPastAbort = true - - expect(continuedPastAbort).toBe false - - expect(buffer.getText()).toBe "hello\nworms\r\nhow are you doing?" - - buffer.undo() - expect(buffer.getText()).toBe "hello\nworld\r\nhow are you doing?" - - buffer.redo() - expect(buffer.getText()).toBe "hello\nworms\r\nhow are you doing?" - - buffer.redo() - expect(buffer.getText()).toBe "hello\nworms\r\nhow are you doing?" - - it "preserves the redo stack until a content change occurs", -> - buffer.undo() - expect(buffer.getText()).toBe "hello\nworld\r\nhow are you doing?" - - # no changes occur in this transaction before aborting - buffer.transact -> - buffer.markRange([[0, 0], [0, 5]]) - buffer.abortTransaction() - buffer.setTextInRange([[0, 0], [0, 5]], "hey") - - buffer.redo() - expect(buffer.getText()).toBe "hello\nworms\r\nhow are you doing?" - - buffer.undo() - expect(buffer.getText()).toBe "hello\nworld\r\nhow are you doing?" - - buffer.transact -> - buffer.setTextInRange([[0, 0], [0, 5]], "hey") - buffer.abortTransaction() - expect(buffer.getText()).toBe "hello\nworld\r\nhow are you doing?" - - buffer.redo() - expect(buffer.getText()).toBe "hello\nworld\r\nhow are you doing?" - - it "allows nested transactions", -> - expect(buffer.getText()).toBe "hello\nworms\r\nhow are you doing?" - - buffer.transact -> - buffer.setTextInRange([[0, 2], [0, 5]], "y") - buffer.transact -> - buffer.setTextInRange([[2, 13], [2, 14]], "igg") - buffer.setTextInRange([[2, 18], [2, 19]], "'") - expect(buffer.getText()).toBe "hey\nworms\r\nhow are you diggin'?" - buffer.undo() - expect(buffer.getText()).toBe "hey\nworms\r\nhow are you doing?" - buffer.redo() - expect(buffer.getText()).toBe "hey\nworms\r\nhow are you diggin'?" - - buffer.undo() - expect(buffer.getText()).toBe "hello\nworms\r\nhow are you doing?" - - buffer.redo() - expect(buffer.getText()).toBe "hey\nworms\r\nhow are you diggin'?" - - buffer.undo() - buffer.undo() - expect(buffer.getText()).toBe "hello\nworld\r\nhow are you doing?" - - it "groups adjacent transactions within each other's grouping intervals", -> - now += 1000 - buffer.transact 101, -> buffer.setTextInRange([[0, 2], [0, 5]], "y") - - now += 100 - buffer.transact 201, -> buffer.setTextInRange([[0, 3], [0, 3]], "yy") - - now += 200 - buffer.transact 201, -> buffer.setTextInRange([[0, 5], [0, 5]], "yy") - - # not grouped because the previous transaction's grouping interval - # is only 200ms and we've advanced 300ms - now += 300 - buffer.transact 301, -> buffer.setTextInRange([[0, 7], [0, 7]], "!!") - - expect(buffer.getText()).toBe "heyyyyy!!\nworms\r\nhow are you doing?" - - buffer.undo() - expect(buffer.getText()).toBe "heyyyyy\nworms\r\nhow are you doing?" - - buffer.undo() - expect(buffer.getText()).toBe "hello\nworms\r\nhow are you doing?" - - buffer.redo() - expect(buffer.getText()).toBe "heyyyyy\nworms\r\nhow are you doing?" - - buffer.redo() - expect(buffer.getText()).toBe "heyyyyy!!\nworms\r\nhow are you doing?" - - it "allows undo/redo within transactions, but not beyond the start of the containing transaction", -> - buffer.setText("") - buffer.markPosition([0, 0]) - - buffer.append("a") - - buffer.transact -> - buffer.append("b") - buffer.transact -> buffer.append("c") - buffer.append("d") - - expect(buffer.undo()).toBe true - expect(buffer.getText()).toBe "abc" - - expect(buffer.undo()).toBe true - expect(buffer.getText()).toBe "ab" - - expect(buffer.undo()).toBe true - expect(buffer.getText()).toBe "a" - - expect(buffer.undo()).toBe false - expect(buffer.getText()).toBe "a" - - expect(buffer.redo()).toBe true - expect(buffer.getText()).toBe "ab" - - expect(buffer.redo()).toBe true - expect(buffer.getText()).toBe "abc" - - expect(buffer.redo()).toBe true - expect(buffer.getText()).toBe "abcd" - - expect(buffer.redo()).toBe false - expect(buffer.getText()).toBe "abcd" - - expect(buffer.undo()).toBe true - expect(buffer.getText()).toBe "a" - - it "does not error if the buffer is destroyed in a change callback within the transaction", -> - buffer.onDidChange -> buffer.destroy() - result = buffer.transact -> - buffer.append('!') - 'hi' - expect(result).toBe('hi') - - describe "checkpoints", -> - beforeEach -> - buffer = new TextBuffer - - describe "::getChangesSinceCheckpoint(checkpoint)", -> - it "returns a list of changes that have been made since the checkpoint", -> - buffer.setText('abc\ndef\nghi\njkl\n') - buffer.append("mno\n") - checkpoint = buffer.createCheckpoint() - buffer.transact -> - buffer.append('pqr\n') - buffer.append('stu\n') - buffer.append('vwx\n') - buffer.setTextInRange([[1, 0], [1, 2]], 'yz') - - expect(buffer.getText()).toBe 'abc\nyzf\nghi\njkl\nmno\npqr\nstu\nvwx\n' - assertChangesEqual(buffer.getChangesSinceCheckpoint(checkpoint), [ - { - oldRange: [[1, 0], [1, 2]], - newRange: [[1, 0], [1, 2]], - oldText: "de", - newText: "yz", - }, - { - oldRange: [[5, 0], [5, 0]], - newRange: [[5, 0], [8, 0]], - oldText: "", - newText: "pqr\nstu\nvwx\n", - } - ]) - - it "returns an empty list of changes when no change has been made since the checkpoint", -> - checkpoint = buffer.createCheckpoint() - expect(buffer.getChangesSinceCheckpoint(checkpoint)).toEqual [] - - it "returns an empty list of changes when the checkpoint doesn't exist", -> - buffer.transact -> - buffer.append('abc\n') - buffer.append('def\n') - buffer.append('ghi\n') - expect(buffer.getChangesSinceCheckpoint(-1)).toEqual [] - - describe "::revertToCheckpoint(checkpoint)", -> - it "undoes all changes following the checkpoint", -> - buffer.append("hello") - checkpoint = buffer.createCheckpoint() - - buffer.transact -> - buffer.append("\n") - buffer.append("world") - - buffer.append("\n") - buffer.append("how are you?") - - result = buffer.revertToCheckpoint(checkpoint) - expect(result).toBe(true) - expect(buffer.getText()).toBe("hello") - - buffer.redo() - expect(buffer.getText()).toBe("hello") - - describe "::groupChangesSinceCheckpoint(checkpoint)", -> - it "combines all changes since the checkpoint into a single transaction", -> - historyLayer = buffer.addMarkerLayer(maintainHistory: true) - - buffer.append("one\n") - marker = historyLayer.markRange([[0, 1], [0, 2]]) - marker.setProperties(a: 'b') - - checkpoint = buffer.createCheckpoint() - buffer.append("two\n") - buffer.transact -> - buffer.append("three\n") - buffer.append("four") - - marker.setRange([[0, 1], [2, 3]]) - marker.setProperties(a: 'c') - result = buffer.groupChangesSinceCheckpoint(checkpoint) - - expect(result).toBeTruthy() - expect(buffer.getText()).toBe """ - one - two - three - four - """ - expect(marker.getRange()).toEqual [[0, 1], [2, 3]] - expect(marker.getProperties()).toEqual {a: 'c'} - - buffer.undo() - expect(buffer.getText()).toBe("one\n") - expect(marker.getRange()).toEqual [[0, 1], [0, 2]] - expect(marker.getProperties()).toEqual {a: 'b'} - - buffer.redo() - expect(buffer.getText()).toBe """ - one - two - three - four - """ - expect(marker.getRange()).toEqual [[0, 1], [2, 3]] - expect(marker.getProperties()).toEqual {a: 'c'} - - it "skips any later checkpoints when grouping changes", -> - buffer.append("one\n") - checkpoint = buffer.createCheckpoint() - buffer.append("two\n") - checkpoint2 = buffer.createCheckpoint() - buffer.append("three") - - buffer.groupChangesSinceCheckpoint(checkpoint) - expect(buffer.revertToCheckpoint(checkpoint2)).toBe(false) - - expect(buffer.getText()).toBe """ - one - two - three - """ - - buffer.undo() - expect(buffer.getText()).toBe("one\n") - - buffer.redo() - expect(buffer.getText()).toBe """ - one - two - three - """ - - it "does nothing when no changes have been made since the checkpoint", -> - buffer.append("one\n") - checkpoint = buffer.createCheckpoint() - result = buffer.groupChangesSinceCheckpoint(checkpoint) - expect(result).toBeTruthy() - buffer.undo() - expect(buffer.getText()).toBe "" - - it "returns false and does nothing when the checkpoint is not in the buffer's history", -> - buffer.append("hello\n") - checkpoint = buffer.createCheckpoint() - buffer.undo() - buffer.append("world") - result = buffer.groupChangesSinceCheckpoint(checkpoint) - expect(result).toBeFalsy() - buffer.undo() - expect(buffer.getText()).toBe "" - - it "skips checkpoints when undoing", -> - buffer.append("hello") - buffer.createCheckpoint() - buffer.createCheckpoint() - buffer.createCheckpoint() - buffer.undo() - expect(buffer.getText()).toBe("") - - it "preserves checkpoints across undo and redo", -> - buffer.append("a") - buffer.append("b") - checkpoint1 = buffer.createCheckpoint() - buffer.append("c") - checkpoint2 = buffer.createCheckpoint() - - buffer.undo() - expect(buffer.getText()).toBe("ab") - - buffer.redo() - expect(buffer.getText()).toBe("abc") - - buffer.append("d") - - expect(buffer.revertToCheckpoint(checkpoint2)).toBe true - expect(buffer.getText()).toBe("abc") - expect(buffer.revertToCheckpoint(checkpoint1)).toBe true - expect(buffer.getText()).toBe("ab") - - it "handles checkpoints created when there have been no changes", -> - checkpoint = buffer.createCheckpoint() - buffer.undo() - buffer.append("hello") - buffer.revertToCheckpoint(checkpoint) - expect(buffer.getText()).toBe("") - - it "returns false when the checkpoint is not in the buffer's history", -> - buffer.append("hello\n") - checkpoint = buffer.createCheckpoint() - buffer.undo() - buffer.append("world") - expect(buffer.revertToCheckpoint(checkpoint)).toBe(false) - expect(buffer.getText()).toBe("world") - - it "does not allow changes based on checkpoints outside of the current transaction", -> - checkpoint = buffer.createCheckpoint() - - buffer.append("a") - - buffer.transact -> - expect(buffer.revertToCheckpoint(checkpoint)).toBe false - expect(buffer.getText()).toBe "a" - - buffer.append("b") - - expect(buffer.groupChangesSinceCheckpoint(checkpoint)).toBeFalsy() - - buffer.undo() - expect(buffer.getText()).toBe "a" - - describe "::groupLastChanges()", -> - it "groups the last two changes into a single transaction", -> - buffer = new TextBuffer() - layer = buffer.addMarkerLayer({maintainHistory: true}) - - buffer.append('a') - - # Group two transactions, ensure before/after markers snapshots are preserved - marker = layer.markPosition([0, 0]) - buffer.transact -> - buffer.append('b') - buffer.createCheckpoint() - buffer.transact -> - buffer.append('ccc') - marker.setHeadPosition([0, 2]) - - expect(buffer.groupLastChanges()).toBe(true) - buffer.undo() - expect(marker.getHeadPosition()).toEqual([0, 0]) - expect(buffer.getText()).toBe('a') - buffer.redo() - expect(marker.getHeadPosition()).toEqual([0, 2]) - buffer.undo() - - # Group two bare changes - buffer.transact -> - buffer.append('b') - buffer.createCheckpoint() - buffer.append('c') - expect(buffer.groupLastChanges()).toBe(true) - buffer.undo() - expect(buffer.getText()).toBe('a') - - # Group a transaction with a bare change - buffer.transact -> - buffer.transact -> - buffer.append('b') - buffer.append('c') - buffer.append('d') - expect(buffer.groupLastChanges()).toBe(true) - buffer.undo() - expect(buffer.getText()).toBe('a') - - # Group a bare change with a transaction - buffer.transact -> - buffer.append('b') - buffer.transact -> - buffer.append('c') - buffer.append('d') - expect(buffer.groupLastChanges()).toBe(true) - buffer.undo() - expect(buffer.getText()).toBe('a') - - # Can't group past the beginning of an open transaction - buffer.transact -> - expect(buffer.groupLastChanges()).toBe(false) - buffer.append('b') - expect(buffer.groupLastChanges()).toBe(false) - buffer.append('c') - expect(buffer.groupLastChanges()).toBe(true) - buffer.undo() - expect(buffer.getText()).toBe('a') - - describe "::setHistoryProvider(provider)", -> - it "replaces the currently active history provider with the passed one", -> - buffer = new TextBuffer({text: ''}) - buffer.insert([0, 0], 'Lorem ') - buffer.insert([0, 6], 'ipsum ') - expect(buffer.getText()).toBe('Lorem ipsum ') - - buffer.undo() - expect(buffer.getText()).toBe('Lorem ') - - buffer.setHistoryProvider(new DefaultHistoryProvider(buffer)) - buffer.undo() - expect(buffer.getText()).toBe('Lorem ') - - buffer.insert([0, 6], 'dolor ') - expect(buffer.getText()).toBe('Lorem dolor ') - - buffer.undo() - expect(buffer.getText()).toBe('Lorem ') - - describe "::getHistory(maxEntries) and restoreDefaultHistoryProvider(history)", -> - it "returns a base text and the state of the last `maxEntries` entries in the undo and redo stacks", -> - buffer = new TextBuffer({text: ''}) - markerLayer = buffer.addMarkerLayer({maintainHistory: true}) - - buffer.append('Lorem ') - buffer.append('ipsum ') - buffer.append('dolor ') - markerLayer.markPosition([0, 2]) - markersSnapshotAtCheckpoint1 = buffer.createMarkerSnapshot() - checkpoint1 = buffer.createCheckpoint() - buffer.append('sit ') - buffer.append('amet ') - buffer.append('consecteur ') - markerLayer.markPosition([0, 4]) - markersSnapshotAtCheckpoint2 = buffer.createMarkerSnapshot() - checkpoint2 = buffer.createCheckpoint() - buffer.append('adipiscit ') - buffer.append('elit ') - buffer.undo() - buffer.undo() - buffer.undo() - - history = buffer.getHistory(3) - expect(history.baseText).toBe('Lorem ipsum dolor ') - expect(history.nextCheckpointId).toBe(buffer.createCheckpoint()) - expect(history.undoStack).toEqual([ - { - type: 'checkpoint', - id: checkpoint1, - markers: markersSnapshotAtCheckpoint1 - }, - { - type: 'transaction', - changes: [{oldStart: Point(0, 18), oldEnd: Point(0, 18), newStart: Point(0, 18), newEnd: Point(0, 22), oldText: '', newText: 'sit '}], - markersBefore: markersSnapshotAtCheckpoint1, - markersAfter: markersSnapshotAtCheckpoint1 - }, - { - type: 'transaction', - changes: [{oldStart: Point(0, 22), oldEnd: Point(0, 22), newStart: Point(0, 22), newEnd: Point(0, 27), oldText: '', newText: 'amet '}], - markersBefore: markersSnapshotAtCheckpoint1, - markersAfter: markersSnapshotAtCheckpoint1 - } - ]) - expect(history.redoStack).toEqual([ - { - type: 'transaction', - changes: [{oldStart: Point(0, 38), oldEnd: Point(0, 38), newStart: Point(0, 38), newEnd: Point(0, 48), oldText: '', newText: 'adipiscit '}], - markersBefore: markersSnapshotAtCheckpoint2, - markersAfter: markersSnapshotAtCheckpoint2 - }, - { - type: 'checkpoint', - id: checkpoint2, - markers: markersSnapshotAtCheckpoint2 - }, - { - type: 'transaction', - changes: [{oldStart: Point(0, 27), oldEnd: Point(0, 27), newStart: Point(0, 27), newEnd: Point(0, 38), oldText: '', newText: 'consecteur '}], - markersBefore: markersSnapshotAtCheckpoint1, - markersAfter: markersSnapshotAtCheckpoint1 - } - ]) - - buffer.createCheckpoint() - buffer.append('x') - buffer.undo() - buffer.clearUndoStack() - - expect(buffer.getHistory()).not.toEqual(history) - buffer.restoreDefaultHistoryProvider(history) - expect(buffer.getHistory()).toEqual(history) - - it "throws an error when called within a transaction", -> - buffer = new TextBuffer() - expect(-> - buffer.transact(-> buffer.getHistory(3)) - ).toThrowError() - - describe "::getTextInRange(range)", -> - it "returns the text in a given range", -> - buffer = new TextBuffer(text: "hello\nworld\r\nhow are you doing?") - expect(buffer.getTextInRange([[1, 1], [1, 4]])).toBe "orl" - expect(buffer.getTextInRange([[0, 3], [2, 3]])).toBe "lo\nworld\r\nhow" - expect(buffer.getTextInRange([[0, 0], [2, 18]])).toBe buffer.getText() - - it "clips the given range", -> - buffer = new TextBuffer(text: "hello\nworld\r\nhow are you doing?") - expect(buffer.getTextInRange([[-100, -100], [100, 100]])).toBe buffer.getText() - - describe "::clipPosition(position)", -> - it "returns a valid position closest to the given position", -> - buffer = new TextBuffer(text: "hello\nworld\r\nhow are you doing?") - expect(buffer.clipPosition([-1, -1])).toEqual [0, 0] - expect(buffer.clipPosition([-1, 2])).toEqual [0, 0] - expect(buffer.clipPosition([0, -1])).toEqual [0, 0] - expect(buffer.clipPosition([0, 20])).toEqual [0, 5] - expect(buffer.clipPosition([1, -1])).toEqual [1, 0] - expect(buffer.clipPosition([1, 20])).toEqual [1, 5] - expect(buffer.clipPosition([10, 0])).toEqual [2, 18] - expect(buffer.clipPosition([Infinity, 0])).toEqual [2, 18] - - it "throws an error when given an invalid point", -> - buffer = new TextBuffer(text: "hello\nworld\r\nhow are you doing?") - expect -> buffer.clipPosition([NaN, 1]) - .toThrowError("Invalid Point: (NaN, 1)") - expect -> buffer.clipPosition([0, NaN]) - .toThrowError("Invalid Point: (0, NaN)") - expect -> buffer.clipPosition([0, {}]) - .toThrowError("Invalid Point: (0, [object Object])") - - describe "::characterIndexForPosition(position)", -> - beforeEach -> - buffer = new TextBuffer(text: "zero\none\r\ntwo\nthree") - - it "returns the absolute character offset for the given position", -> - expect(buffer.characterIndexForPosition([0, 0])).toBe 0 - expect(buffer.characterIndexForPosition([0, 1])).toBe 1 - expect(buffer.characterIndexForPosition([0, 4])).toBe 4 - expect(buffer.characterIndexForPosition([1, 0])).toBe 5 - expect(buffer.characterIndexForPosition([1, 1])).toBe 6 - expect(buffer.characterIndexForPosition([1, 3])).toBe 8 - expect(buffer.characterIndexForPosition([2, 0])).toBe 10 - expect(buffer.characterIndexForPosition([2, 1])).toBe 11 - expect(buffer.characterIndexForPosition([3, 0])).toBe 14 - expect(buffer.characterIndexForPosition([3, 5])).toBe 19 - - it "clips the given position before translating", -> - expect(buffer.characterIndexForPosition([-1, -1])).toBe 0 - expect(buffer.characterIndexForPosition([1, 100])).toBe 8 - expect(buffer.characterIndexForPosition([100, 100])).toBe 19 - - describe "::positionForCharacterIndex(offset)", -> - beforeEach -> - buffer = new TextBuffer(text: "zero\none\r\ntwo\nthree") - - it "returns the position for the given absolute character offset", -> - expect(buffer.positionForCharacterIndex(0)).toEqual [0, 0] - expect(buffer.positionForCharacterIndex(1)).toEqual [0, 1] - expect(buffer.positionForCharacterIndex(4)).toEqual [0, 4] - expect(buffer.positionForCharacterIndex(5)).toEqual [1, 0] - expect(buffer.positionForCharacterIndex(6)).toEqual [1, 1] - expect(buffer.positionForCharacterIndex(8)).toEqual [1, 3] - expect(buffer.positionForCharacterIndex(10)).toEqual [2, 0] - expect(buffer.positionForCharacterIndex(11)).toEqual [2, 1] - expect(buffer.positionForCharacterIndex(14)).toEqual [3, 0] - expect(buffer.positionForCharacterIndex(19)).toEqual [3, 5] - - it "clips the given offset before translating", -> - expect(buffer.positionForCharacterIndex(-1)).toEqual [0, 0] - expect(buffer.positionForCharacterIndex(20)).toEqual [3, 5] - - describe "serialization", -> - expectSameMarkers = (left, right) -> - markers1 = left.getMarkers().sort (a, b) -> a.compare(b) - markers2 = right.getMarkers().sort (a, b) -> a.compare(b) - expect(markers1.length).toBe markers2.length - for marker1, i in markers1 - expect(marker1).toEqual(markers2[i]) - return - - it "can serialize / deserialize the buffer along with its history, marker layers, and display layers", (done) -> - bufferA = new TextBuffer(text: "hello\nworld\r\nhow are you doing?") - displayLayer1A = bufferA.addDisplayLayer() - displayLayer2A = bufferA.addDisplayLayer() - displayLayer1A.foldBufferRange([[0, 1], [0, 3]]) - displayLayer2A.foldBufferRange([[0, 0], [0, 2]]) - bufferA.createCheckpoint() - bufferA.setTextInRange([[0, 5], [0, 5]], " there") - bufferA.transact -> bufferA.setTextInRange([[1, 0], [1, 5]], "friend") - layerA = bufferA.addMarkerLayer(maintainHistory: true, persistent: true) - layerA.markRange([[0, 6], [0, 8]], reversed: true, foo: 1) - layerB = bufferA.addMarkerLayer(maintainHistory: true, persistent: true, role: "selections") - marker2A = bufferA.markPosition([2, 2], bar: 2) - bufferA.transact -> - bufferA.setTextInRange([[1, 0], [1, 0]], "good ") - bufferA.append("?") - marker2A.setProperties(bar: 3, baz: 4) - layerA.markRange([[0, 4], [0, 5]], invalidate: 'inside') - bufferA.setTextInRange([[0, 5], [0, 5]], "oo") - bufferA.undo() - - state = JSON.parse(JSON.stringify(bufferA.serialize())) - TextBuffer.deserialize(state).then (bufferB) -> - expect(bufferB.getText()).toBe "hello there\ngood friend\r\nhow are you doing??" - expectSameMarkers(bufferB.getMarkerLayer(layerA.id), layerA) - expect(bufferB.getDisplayLayer(displayLayer1A.id).foldsIntersectingBufferRange([[0, 1], [0, 3]]).length).toBe(1) - expect(bufferB.getDisplayLayer(displayLayer2A.id).foldsIntersectingBufferRange([[0, 0], [0, 2]]).length).toBe(1) - displayLayer3B = bufferB.addDisplayLayer() - expect(displayLayer3B.id).toBeGreaterThan(displayLayer1A.id) - expect(displayLayer3B.id).toBeGreaterThan(displayLayer2A.id) - - expect(bufferB.getMarkerLayer(layerB.id).getRole()).toBe "selections" - expect(bufferB.selectionsMarkerLayerIds.has(layerB.id)).toBe true - expect(bufferB.selectionsMarkerLayerIds.size).toBe 1 - - bufferA.redo() - bufferB.redo() - expect(bufferB.getText()).toBe "hellooo there\ngood friend\r\nhow are you doing??" - expectSameMarkers(bufferB.getMarkerLayer(layerA.id), layerA) - expect(bufferB.getMarkerLayer(layerA.id).maintainHistory).toBe true - expect(bufferB.getMarkerLayer(layerA.id).persistent).toBe true - - bufferA.undo() - bufferB.undo() - expect(bufferB.getText()).toBe "hello there\ngood friend\r\nhow are you doing??" - expectSameMarkers(bufferB.getMarkerLayer(layerA.id), layerA) - - bufferA.undo() - bufferB.undo() - expect(bufferB.getText()).toBe "hello there\nfriend\r\nhow are you doing?" - expectSameMarkers(bufferB.getMarkerLayer(layerA.id), layerA) - - bufferA.undo() - bufferB.undo() - expect(bufferB.getText()).toBe "hello there\nworld\r\nhow are you doing?" - expectSameMarkers(bufferB.getMarkerLayer(layerA.id), layerA) - - bufferA.undo() - bufferB.undo() - expect(bufferB.getText()).toBe "hello\nworld\r\nhow are you doing?" - expectSameMarkers(bufferB.getMarkerLayer(layerA.id), layerA) - - # Accounts for deserialized markers when selecting the next marker's id - marker3A = layerA.markRange([[0, 1], [2, 3]]) - marker3B = bufferB.getMarkerLayer(layerA.id).markRange([[0, 1], [2, 3]]) - expect(marker3B.id).toBe marker3A.id - - # Doesn't try to reload the buffer since it has no file. - setTimeout(-> - expect(bufferB.getText()).toBe "hello\nworld\r\nhow are you doing?" - done() - , 50) - - it "serializes / deserializes the buffer's persistent custom marker layers", (done) -> - bufferA = new TextBuffer("abcdefghijklmnopqrstuvwxyz") - - layer1A = bufferA.addMarkerLayer() - layer2A = bufferA.addMarkerLayer(persistent: true) - - layer1A.markRange([[0, 1], [0, 2]]) - layer1A.markRange([[0, 3], [0, 4]]) - - layer2A.markRange([[0, 5], [0, 6]]) - layer2A.markRange([[0, 7], [0, 8]]) - - TextBuffer.deserialize(JSON.parse(JSON.stringify(bufferA.serialize()))).then (bufferB) -> - layer1B = bufferB.getMarkerLayer(layer1A.id) - layer2B = bufferB.getMarkerLayer(layer2A.id) - expect(layer2B.persistent).toBe true - - expect(layer1B).toBe undefined - expectSameMarkers(layer2A, layer2B) - done() - - it "doesn't serialize the default marker layer", (done) -> - bufferA = new TextBuffer(text: "hello\nworld\r\nhow are you doing?") - markerLayerA = bufferA.getDefaultMarkerLayer() - marker1A = bufferA.markRange([[0, 1], [1, 2]], foo: 1) - - TextBuffer.deserialize(bufferA.serialize()).then (bufferB) -> - markerLayerB = bufferB.getDefaultMarkerLayer() - expect(bufferB.getMarker(marker1A.id)).toBeUndefined() - done() - - it "doesn't attempt to serialize snapshots for destroyed marker layers", -> - buffer = new TextBuffer(text: "abc") - markerLayer = buffer.addMarkerLayer(maintainHistory: true, persistent: true) - markerLayer.markPosition([0, 3]) - buffer.insert([0, 0], 'x') - markerLayer.destroy() - - expect(-> buffer.serialize()).not.toThrowError() - - it "doesn't remember marker layers when calling serialize with {markerLayers: false}", (done) -> - bufferA = new TextBuffer(text: "world") - layerA = bufferA.addMarkerLayer(maintainHistory: true) - markerA = layerA.markPosition([0, 3]) - markerB = null - bufferA.transact -> - bufferA.insert([0, 0], 'hello ') - markerB = layerA.markPosition([0, 5]) - bufferA.undo() - - TextBuffer.deserialize(bufferA.serialize({markerLayers: false})).then (bufferB) -> - expect(bufferB.getText()).toBe("world") - expect(bufferB.getMarkerLayer(layerA.id)?.getMarker(markerA.id)).toBeUndefined() - expect(bufferB.getMarkerLayer(layerA.id)?.getMarker(markerB.id)).toBeUndefined() - - bufferB.redo() - expect(bufferB.getText()).toBe("hello world") - expect(bufferB.getMarkerLayer(layerA.id)?.getMarker(markerA.id)).toBeUndefined() - expect(bufferB.getMarkerLayer(layerA.id)?.getMarker(markerB.id)).toBeUndefined() - - bufferB.undo() - expect(bufferB.getText()).toBe("world") - expect(bufferB.getMarkerLayer(layerA.id)?.getMarker(markerA.id)).toBeUndefined() - expect(bufferB.getMarkerLayer(layerA.id)?.getMarker(markerB.id)).toBeUndefined() - done() - - it "doesn't remember history when calling serialize with {history: false}", (done) -> - bufferA = new TextBuffer(text: 'abc') - bufferA.append('def') - bufferA.append('ghi') - - TextBuffer.deserialize(bufferA.serialize({history: false})).then (bufferB) -> - expect(bufferB.getText()).toBe("abcdefghi") - expect(bufferB.undo()).toBe(false) - expect(bufferB.getText()).toBe("abcdefghi") - done() - - it "serializes / deserializes the buffer's unique identifier", (done) -> - bufferA = new TextBuffer() - TextBuffer.deserialize(JSON.parse(JSON.stringify(bufferA.serialize()))).then (bufferB) -> - expect(bufferB.getId()).toEqual(bufferA.getId()) - done() - - it "doesn't deserialize a state that was serialized with a different buffer version", (done) -> - bufferA = new TextBuffer() - serializedBuffer = JSON.parse(JSON.stringify(bufferA.serialize())) - serializedBuffer.version = 123456789 - - TextBuffer.deserialize(serializedBuffer).then (bufferB) -> - expect(bufferB).toBeUndefined() - done() - - it "doesn't deserialize a state referencing a file that no longer exists", (done) -> - tempDir = fs.realpathSync(temp.mkdirSync('text-buffer')) - filePath = join(tempDir, 'file.txt') - fs.writeFileSync(filePath, "something\n") - - bufferA = TextBuffer.loadSync(filePath) - state = bufferA.serialize() - - fs.unlinkSync(filePath) - - state.mustExist = true - TextBuffer.deserialize(state).then( - -> expect('serialization succeeded with mustExist: true').toBeUndefined(), - (err) -> expect(err.code).toBe('ENOENT') - ).then(done, done) - - describe "when the serialized buffer was unsaved and had no path", -> - it "restores the previous unsaved state of the buffer", (done) -> - buffer = new TextBuffer() - buffer.setText("abc") - - TextBuffer.deserialize(buffer.serialize()).then (buffer2) -> - expect(buffer2.getPath()).toBeUndefined() - expect(buffer2.getText()).toBe("abc") - done() - - describe "::getRange()", -> - it "returns the range of the entire buffer text", -> - buffer = new TextBuffer("abc\ndef\nghi") - expect(buffer.getRange()).toEqual [[0, 0], [2, 3]] - - describe "::getLength()", -> - it "returns the lenght of the entire buffer text", -> - buffer = new TextBuffer("abc\ndef\nghi") - expect(buffer.getLength()).toBe("abc\ndef\nghi".length) - - describe "::rangeForRow(row, includeNewline)", -> - beforeEach -> - buffer = new TextBuffer("this\nis a test\r\ntesting") - - describe "if includeNewline is false (the default)", -> - it "returns a range from the beginning of the line to the end of the line", -> - expect(buffer.rangeForRow(0)).toEqual([[0, 0], [0, 4]]) - expect(buffer.rangeForRow(1)).toEqual([[1, 0], [1, 9]]) - expect(buffer.rangeForRow(2)).toEqual([[2, 0], [2, 7]]) - - describe "if includeNewline is true", -> - it "returns a range from the beginning of the line to the beginning of the next (if it exists)", -> - expect(buffer.rangeForRow(0, true)).toEqual([[0, 0], [1, 0]]) - expect(buffer.rangeForRow(1, true)).toEqual([[1, 0], [2, 0]]) - expect(buffer.rangeForRow(2, true)).toEqual([[2, 0], [2, 7]]) - - describe "if the given row is out of range", -> - it "returns the range of the nearest valid row", -> - expect(buffer.rangeForRow(-1)).toEqual([[0, 0], [0, 4]]) - expect(buffer.rangeForRow(10)).toEqual([[2, 0], [2, 7]]) - - describe "::onDidChangePath()", -> - [filePath, newPath, bufferToChange, eventHandler] = [] - - beforeEach -> - tempDir = fs.realpathSync(temp.mkdirSync('text-buffer')) - filePath = join(tempDir, "manipulate-me") - newPath = "#{filePath}-i-moved" - fs.writeFileSync(filePath, "") - bufferToChange = TextBuffer.loadSync(filePath) - - afterEach -> - bufferToChange.destroy() - fs.removeSync(filePath) - fs.removeSync(newPath) - - it "notifies observers when the buffer is saved to a new path", (done) -> - bufferToChange.onDidChangePath (p) -> - expect(p).toBe(newPath) - done() - bufferToChange.saveAs(newPath) - - it "notifies observers when the buffer's file is moved", (done) -> - # FIXME: This doesn't pass on Linux - if process.platform in ['linux', 'win32'] - done() - return - - bufferToChange.onDidChangePath (p) -> - expect(p).toBe(newPath) - done() - - fs.removeSync(newPath) - fs.moveSync(filePath, newPath) - - describe "::onWillThrowWatchError", -> - it "notifies observers when the file has a watch error", -> - filePath = temp.openSync('atom').path - fs.writeFileSync(filePath, '') - - buffer = TextBuffer.loadSync(filePath) - - eventHandler = jasmine.createSpy('eventHandler') - buffer.onWillThrowWatchError(eventHandler) - - buffer.file.emitter.emit 'will-throw-watch-error', 'arg' - expect(eventHandler).toHaveBeenCalledWith 'arg' - - describe "::getLines()", -> - it "returns an array of lines in the text contents", -> - filePath = require.resolve('./fixtures/sample.js') - fileContents = fs.readFileSync(filePath, 'utf8') - buffer = TextBuffer.loadSync(filePath) - expect(buffer.getLines().length).toBe fileContents.split("\n").length - expect(buffer.getLines().join('\n')).toBe fileContents - - describe "::setTextInRange(range, string)", -> - changeHandler = null - - beforeEach (done) -> - filePath = require.resolve('./fixtures/sample.js') - fileContents = fs.readFileSync(filePath, 'utf8') - TextBuffer.load(filePath).then (result) -> - buffer = result - changeHandler = jasmine.createSpy('changeHandler') - buffer.onDidChange changeHandler - done() - - describe "when used to insert (called with an empty range and a non-empty string)", -> - describe "when the given string has no newlines", -> - it "inserts the string at the location of the given range", -> - range = [[3, 4], [3, 4]] - buffer.setTextInRange range, "foo" - - expect(buffer.lineForRow(2)).toBe " if (items.length <= 1) return items;" - expect(buffer.lineForRow(3)).toBe " foovar pivot = items.shift(), current, left = [], right = [];" - expect(buffer.lineForRow(4)).toBe " while(items.length > 0) {" - - expect(changeHandler).toHaveBeenCalled() - [event] = changeHandler.calls.allArgs()[0] - expect(event.oldRange).toEqual range - expect(event.newRange).toEqual [[3, 4], [3, 7]] - expect(event.oldText).toBe "" - expect(event.newText).toBe "foo" - - describe "when the given string has newlines", -> - it "inserts the lines at the location of the given range", -> - range = [[3, 4], [3, 4]] - - buffer.setTextInRange range, "foo\n\nbar\nbaz" - - expect(buffer.lineForRow(2)).toBe " if (items.length <= 1) return items;" - expect(buffer.lineForRow(3)).toBe " foo" - expect(buffer.lineForRow(4)).toBe "" - expect(buffer.lineForRow(5)).toBe "bar" - expect(buffer.lineForRow(6)).toBe "bazvar pivot = items.shift(), current, left = [], right = [];" - expect(buffer.lineForRow(7)).toBe " while(items.length > 0) {" - - expect(changeHandler).toHaveBeenCalled() - [event] = changeHandler.calls.allArgs()[0] - expect(event.oldRange).toEqual range - expect(event.newRange).toEqual [[3, 4], [6, 3]] - expect(event.oldText).toBe "" - expect(event.newText).toBe "foo\n\nbar\nbaz" - - describe "when used to remove (called with a non-empty range and an empty string)", -> - describe "when the range is contained within a single line", -> - it "removes the characters within the range", -> - range = [[3, 4], [3, 7]] - buffer.setTextInRange range, "" - - expect(buffer.lineForRow(2)).toBe " if (items.length <= 1) return items;" - expect(buffer.lineForRow(3)).toBe " pivot = items.shift(), current, left = [], right = [];" - expect(buffer.lineForRow(4)).toBe " while(items.length > 0) {" - - expect(changeHandler).toHaveBeenCalled() - [event] = changeHandler.calls.allArgs()[0] - expect(event.oldRange).toEqual range - expect(event.newRange).toEqual [[3, 4], [3, 4]] - expect(event.oldText).toBe "var" - expect(event.newText).toBe "" - - describe "when the range spans 2 lines", -> - it "removes the characters within the range and joins the lines", -> - range = [[3, 16], [4, 4]] - buffer.setTextInRange range, "" - - expect(buffer.lineForRow(2)).toBe " if (items.length <= 1) return items;" - expect(buffer.lineForRow(3)).toBe " var pivot = while(items.length > 0) {" - expect(buffer.lineForRow(4)).toBe " current = items.shift();" - - expect(changeHandler).toHaveBeenCalled() - [event] = changeHandler.calls.allArgs()[0] - expect(event.oldRange).toEqual range - expect(event.newRange).toEqual [[3, 16], [3, 16]] - expect(event.oldText).toBe "items.shift(), current, left = [], right = [];\n " - expect(event.newText).toBe "" - - describe "when the range spans more than 2 lines", -> - it "removes the characters within the range, joining the first and last line and removing the lines in-between", -> - buffer.setTextInRange [[3, 16], [11, 9]], "" - - expect(buffer.lineForRow(2)).toBe " if (items.length <= 1) return items;" - expect(buffer.lineForRow(3)).toBe " var pivot = sort(Array.apply(this, arguments));" - expect(buffer.lineForRow(4)).toBe "};" - - describe "when used to replace text with other text (called with non-empty range and non-empty string)", -> - it "replaces the old text with the new text", -> - range = [[3, 16], [11, 9]] - oldText = buffer.getTextInRange(range) - - buffer.setTextInRange range, "foo\nbar" - - expect(buffer.lineForRow(2)).toBe " if (items.length <= 1) return items;" - expect(buffer.lineForRow(3)).toBe " var pivot = foo" - expect(buffer.lineForRow(4)).toBe "barsort(Array.apply(this, arguments));" - expect(buffer.lineForRow(5)).toBe "};" - - expect(changeHandler).toHaveBeenCalled() - [event] = changeHandler.calls.allArgs()[0] - expect(event.oldRange).toEqual range - expect(event.newRange).toEqual [[3, 16], [4, 3]] - expect(event.oldText).toBe oldText - expect(event.newText).toBe "foo\nbar" - - it "allows a change to be undone safely from an ::onDidChange callback", -> - buffer.onDidChange -> buffer.undo() - buffer.setTextInRange([[0, 0], [0, 0]], "hello") - expect(buffer.lineForRow(0)).toBe "var quicksort = function () {" - - describe "::setText(text)", -> - beforeEach -> - filePath = require.resolve('./fixtures/sample.js') - buffer = TextBuffer.loadSync(filePath) - - describe "when the buffer contains newlines", -> - it "changes the entire contents of the buffer and emits a change event", -> - lastRow = buffer.getLastRow() - expectedPreRange = [[0, 0], [lastRow, buffer.lineForRow(lastRow).length]] - changeHandler = jasmine.createSpy('changeHandler') - buffer.onDidChange changeHandler - - newText = "I know you are.\nBut what am I?" - buffer.setText(newText) - - expect(buffer.getText()).toBe newText - expect(changeHandler).toHaveBeenCalled() - - [event] = changeHandler.calls.allArgs()[0] - expect(event.newText).toBe newText - expect(event.oldRange).toEqual expectedPreRange - expect(event.newRange).toEqual [[0, 0], [1, 14]] - - describe "with windows newlines", -> - it "changes the entire contents of the buffer", -> - buffer = new TextBuffer("first\r\nlast") - lastRow = buffer.getLastRow() - expectedPreRange = [[0, 0], [lastRow, buffer.lineForRow(lastRow).length]] - changeHandler = jasmine.createSpy('changeHandler') - buffer.onDidChange changeHandler - - newText = "new first\r\nnew last" - buffer.setText(newText) - - expect(buffer.getText()).toBe newText - expect(changeHandler).toHaveBeenCalled() - - [event] = changeHandler.calls.allArgs()[0] - expect(event.newText).toBe newText - expect(event.oldRange).toEqual expectedPreRange - expect(event.newRange).toEqual [[0, 0], [1, 8]] - - describe "::setTextViaDiff(text)", -> - beforeEach (done) -> - filePath = require.resolve('./fixtures/sample.js') - TextBuffer.load(filePath).then (result) -> - buffer = result - done() - - it "can change the entire contents of the buffer when there are no newlines", -> - buffer.setText('BUFFER CHANGE') - newText = 'DISK CHANGE' - buffer.setTextViaDiff(newText) - expect(buffer.getText()).toBe newText - - it "can change a buffer that contains lone carriage returns", -> - oldText = 'one\rtwo\nthree\rfour\n' - newText = 'one\rtwo and\nthree\rfour\n' - buffer.setText(oldText) - buffer.setTextViaDiff(newText) - expect(buffer.getText()).toBe newText - buffer.undo() - expect(buffer.getText()).toBe oldText - - describe "with standard newlines", -> - it "can change the entire contents of the buffer with no newline at the end", -> - newText = "I know you are.\nBut what am I?" - buffer.setTextViaDiff(newText) - expect(buffer.getText()).toBe newText - - it "can change the entire contents of the buffer with a newline at the end", -> - newText = "I know you are.\nBut what am I?\n" - buffer.setTextViaDiff(newText) - expect(buffer.getText()).toBe newText - - it "can change a few lines at the beginning in the buffer", -> - newText = buffer.getText().replace(/function/g, 'omgwow') - buffer.setTextViaDiff(newText) - expect(buffer.getText()).toBe newText - - it "can change a few lines in the middle of the buffer", -> - newText = buffer.getText().replace(/shift/g, 'omgwow') - buffer.setTextViaDiff(newText) - expect(buffer.getText()).toBe newText - - it "can adds a newline at the end", -> - newText = buffer.getText() + '\n' - buffer.setTextViaDiff(newText) - expect(buffer.getText()).toBe newText - - describe "with windows newlines", -> - beforeEach -> - buffer.setText(buffer.getText().replace(/\n/g, '\r\n')) - - it "adds a newline at the end", -> - newText = buffer.getText() + '\r\n' - buffer.setTextViaDiff(newText) - expect(buffer.getText()).toBe newText - - it "changes the entire contents of the buffer with smaller content with no newline at the end", -> - newText = "I know you are.\r\nBut what am I?" - buffer.setTextViaDiff(newText) - expect(buffer.getText()).toBe newText - - it "changes the entire contents of the buffer with smaller content with newline at the end", -> - newText = "I know you are.\r\nBut what am I?\r\n" - buffer.setTextViaDiff(newText) - expect(buffer.getText()).toBe newText - - it "changes a few lines at the beginning in the buffer", -> - newText = buffer.getText().replace(/function/g, 'omgwow') - buffer.setTextViaDiff(newText) - expect(buffer.getText()).toBe newText - - it "changes a few lines in the middle of the buffer", -> - newText = buffer.getText().replace(/shift/g, 'omgwow') - buffer.setTextViaDiff(newText) - expect(buffer.getText()).toBe newText - - describe "::getTextInRange(range)", -> - beforeEach (done) -> - filePath = require.resolve('./fixtures/sample.js') - TextBuffer.load(filePath).then (result) -> - buffer = result - done() - - describe "when range is empty", -> - it "returns an empty string", -> - range = [[1, 1], [1, 1]] - expect(buffer.getTextInRange(range)).toBe "" - - describe "when range spans one line", -> - it "returns characters in range", -> - range = [[2, 8], [2, 13]] - expect(buffer.getTextInRange(range)).toBe "items" - - lineLength = buffer.lineForRow(2).length - range = [[2, 0], [2, lineLength]] - expect(buffer.getTextInRange(range)).toBe " if (items.length <= 1) return items;" - - describe "when range spans multiple lines", -> - it "returns characters in range (including newlines)", -> - lineLength = buffer.lineForRow(2).length - range = [[2, 0], [3, 0]] - expect(buffer.getTextInRange(range)).toBe " if (items.length <= 1) return items;\n" - - lineLength = buffer.lineForRow(2).length - range = [[2, 10], [4, 10]] - expect(buffer.getTextInRange(range)).toBe "ems.length <= 1) return items;\n var pivot = items.shift(), current, left = [], right = [];\n while(" - - describe "when the range starts before the start of the buffer", -> - it "clips the range to the start of the buffer", -> - expect(buffer.getTextInRange([[-Infinity, -Infinity], [0, Infinity]])).toBe buffer.lineForRow(0) - - describe "when the range ends after the end of the buffer", -> - it "clips the range to the end of the buffer", -> - expect(buffer.getTextInRange([[12], [13, Infinity]])).toBe buffer.lineForRow(12) - - describe "::scan(regex, fn)", -> - beforeEach -> - buffer = TextBuffer.loadSync(require.resolve('./fixtures/sample.js')) - - it "calls the given function with the information about each match", -> - matches = [] - buffer.scan /current/g, (match) -> matches.push(match) - expect(matches.length).toBe 5 - - expect(matches[0].matchText).toBe 'current' - expect(matches[0].range).toEqual [[3, 31], [3, 38]] - expect(matches[0].lineText).toBe ' var pivot = items.shift(), current, left = [], right = [];' - expect(matches[0].lineTextOffset).toBe 0 - expect(matches[0].leadingContextLines.length).toBe 0 - expect(matches[0].trailingContextLines.length).toBe 0 - - expect(matches[1].matchText).toBe 'current' - expect(matches[1].range).toEqual [[5, 6], [5, 13]] - expect(matches[1].lineText).toBe ' current = items.shift();' - expect(matches[1].lineTextOffset).toBe 0 - expect(matches[1].leadingContextLines.length).toBe 0 - expect(matches[1].trailingContextLines.length).toBe 0 - - it "calls the given function with the information about each match including context lines", -> - matches = [] - buffer.scan /current/g, {leadingContextLineCount: 1, trailingContextLineCount: 2}, (match) -> matches.push(match) - expect(matches.length).toBe 5 - - expect(matches[0].matchText).toBe 'current' - expect(matches[0].range).toEqual [[3, 31], [3, 38]] - expect(matches[0].lineText).toBe ' var pivot = items.shift(), current, left = [], right = [];' - expect(matches[0].lineTextOffset).toBe 0 - expect(matches[0].leadingContextLines.length).toBe 1 - expect(matches[0].leadingContextLines[0]).toBe ' if (items.length <= 1) return items;' - expect(matches[0].trailingContextLines.length).toBe 2 - expect(matches[0].trailingContextLines[0]).toBe ' while(items.length > 0) {' - expect(matches[0].trailingContextLines[1]).toBe ' current = items.shift();' - - expect(matches[1].matchText).toBe 'current' - expect(matches[1].range).toEqual [[5, 6], [5, 13]] - expect(matches[1].lineText).toBe ' current = items.shift();' - expect(matches[1].lineTextOffset).toBe 0 - expect(matches[1].leadingContextLines.length).toBe 1 - expect(matches[1].leadingContextLines[0]).toBe ' while(items.length > 0) {' - expect(matches[1].trailingContextLines.length).toBe 2 - expect(matches[1].trailingContextLines[0]).toBe ' current < pivot ? left.push(current) : right.push(current);' - expect(matches[1].trailingContextLines[1]).toBe ' }' - - describe "::backwardsScan(regex, fn)", -> - beforeEach -> - buffer = TextBuffer.loadSync(require.resolve('./fixtures/sample.js')) - - it "calls the given function with the information about each match in backwards order", -> - matches = [] - buffer.backwardsScan /current/g, (match) -> matches.push(match) - expect(matches.length).toBe 5 - - expect(matches[0].matchText).toBe 'current' - expect(matches[0].range).toEqual [[6, 56], [6, 63]] - expect(matches[0].lineText).toBe ' current < pivot ? left.push(current) : right.push(current);' - expect(matches[0].lineTextOffset).toBe 0 - expect(matches[0].leadingContextLines.length).toBe 0 - expect(matches[0].trailingContextLines.length).toBe 0 - - expect(matches[1].matchText).toBe 'current' - expect(matches[1].range).toEqual [[6, 34], [6, 41]] - expect(matches[1].lineText).toBe ' current < pivot ? left.push(current) : right.push(current);' - expect(matches[1].lineTextOffset).toBe 0 - expect(matches[1].leadingContextLines.length).toBe 0 - expect(matches[1].trailingContextLines.length).toBe 0 - - describe "::scanInRange(range, regex, fn)", -> - beforeEach -> - filePath = require.resolve('./fixtures/sample.js') - buffer = TextBuffer.loadSync(filePath) - - describe "when given a regex with a ignore case flag", -> - it "does a case-insensitive search", -> - matches = [] - buffer.scanInRange /cuRRent/i, [[0, 0], [12, 0]], ({match, range}) -> - matches.push(match) - expect(matches.length).toBe 1 - - describe "when given a regex with no global flag", -> - it "calls the iterator with the first match for the given regex in the given range", -> - matches = [] - ranges = [] - buffer.scanInRange /cu(rr)ent/, [[4, 0], [6, 44]], ({match, range}) -> - matches.push(match) - ranges.push(range) - - expect(matches.length).toBe 1 - expect(ranges.length).toBe 1 - - expect(matches[0][0]).toBe 'current' - expect(matches[0][1]).toBe 'rr' - expect(ranges[0]).toEqual [[5, 6], [5, 13]] - - describe "when given a regex with a global flag", -> - it "calls the iterator with each match for the given regex in the given range", -> - matches = [] - ranges = [] - buffer.scanInRange /cu(rr)ent/g, [[4, 0], [6, 59]], ({match, range}) -> - matches.push(match) - ranges.push(range) - - expect(matches.length).toBe 3 - expect(ranges.length).toBe 3 - - expect(matches[0][0]).toBe 'current' - expect(matches[0][1]).toBe 'rr' - expect(ranges[0]).toEqual [[5, 6], [5, 13]] - - expect(matches[1][0]).toBe 'current' - expect(matches[1][1]).toBe 'rr' - expect(ranges[1]).toEqual [[6, 6], [6, 13]] - - expect(matches[2][0]).toBe 'current' - expect(matches[2][1]).toBe 'rr' - expect(ranges[2]).toEqual [[6, 34], [6, 41]] - - describe "when the last regex match exceeds the end of the range", -> - describe "when the portion of the match within the range also matches the regex", -> - it "calls the iterator with the truncated match", -> - matches = [] - ranges = [] - buffer.scanInRange /cu(r*)/g, [[4, 0], [6, 9]], ({match, range}) -> - matches.push(match) - ranges.push(range) - - expect(matches.length).toBe 2 - expect(ranges.length).toBe 2 - - expect(matches[0][0]).toBe 'curr' - expect(matches[0][1]).toBe 'rr' - expect(ranges[0]).toEqual [[5, 6], [5, 10]] - - expect(matches[1][0]).toBe 'cur' - expect(matches[1][1]).toBe 'r' - expect(ranges[1]).toEqual [[6, 6], [6, 9]] - - describe "when the portion of the match within the range does not matches the regex", -> - it "does not call the iterator with the truncated match", -> - matches = [] - ranges = [] - buffer.scanInRange /cu(r*)e/g, [[4, 0], [6, 9]], ({match, range}) -> - matches.push(match) - ranges.push(range) - - expect(matches.length).toBe 1 - expect(ranges.length).toBe 1 - - expect(matches[0][0]).toBe 'curre' - expect(matches[0][1]).toBe 'rr' - expect(ranges[0]).toEqual [[5, 6], [5, 11]] - - describe "when the iterator calls the 'replace' control function with a replacement string", -> - it "replaces each occurrence of the regex match with the string", -> - ranges = [] - buffer.scanInRange /cu(rr)ent/g, [[4, 0], [6, 59]], ({range, replace}) -> - ranges.push(range) - replace("foo") - - expect(ranges[0]).toEqual [[5, 6], [5, 13]] - expect(ranges[1]).toEqual [[6, 6], [6, 13]] - expect(ranges[2]).toEqual [[6, 30], [6, 37]] - - expect(buffer.lineForRow(5)).toBe ' foo = items.shift();' - expect(buffer.lineForRow(6)).toBe ' foo < pivot ? left.push(foo) : right.push(current);' - - it "allows the match to be replaced with the empty string", -> - buffer.scanInRange /current/g, [[4, 0], [6, 59]], ({replace}) -> - replace("") - - expect(buffer.lineForRow(5)).toBe ' = items.shift();' - expect(buffer.lineForRow(6)).toBe ' < pivot ? left.push() : right.push(current);' - - describe "when the iterator calls the 'stop' control function", -> - it "stops the traversal", -> - ranges = [] - buffer.scanInRange /cu(rr)ent/g, [[4, 0], [6, 59]], ({range, stop}) -> - ranges.push(range) - stop() if ranges.length is 2 - - expect(ranges.length).toBe 2 - - it "returns the same results as a regex match on a regular string", -> - regexps = [ - /\w+/g # 1 word - /\w+\n\s*\w+/g, # 2 words separated by an newline (escape sequence) - RegExp("\\w+\n\\s*\w+", 'g'), # 2 words separated by a newline (literal) - /\w+\s+\w+/g, # 2 words separated by some whitespace - /\w+[^\w]+\w+/g, # 2 words separated by anything - /\w+\n\s*\w+\n\s*\w+/g, # 3 words separated by newlines (escape sequence) - RegExp("\\w+\n\\s*\\w+\n\\s*\\w+", 'g'), # 3 words separated by newlines (literal) - /\w+[^\w]+\w+[^\w]+\w+/g, # 3 words separated by anything - ] - - i = 0 - while i < 20 - seed = Date.now() - random = new Random(seed) - - text = buildRandomLines(random, 40) - buffer = new TextBuffer({text}) - buffer.backwardsScanChunkSize = random.intBetween(100, 1000) - - range = getRandomBufferRange(random, buffer) - .union(getRandomBufferRange(random, buffer)) - .union(getRandomBufferRange(random, buffer)) - regex = regexps[random(regexps.length)] - - expectedMatches = buffer.getTextInRange(range).match(regex) ? [] - continue unless expectedMatches.length > 0 - i++ - - forwardRanges = [] - forwardMatches = [] - buffer.scanInRange regex, range, ({range, matchText}) -> - forwardRanges.push(range) - forwardMatches.push(matchText) - expect(forwardMatches).toEqual(expectedMatches, "Seed: #{seed}") - - backwardRanges = [] - backwardMatches = [] - buffer.backwardsScanInRange regex, range, ({range, matchText}) -> - backwardRanges.push(range) - backwardMatches.push(matchText) - expect(backwardMatches).toEqual(expectedMatches.reverse(), "Seed: #{seed}") - - it "does not return empty matches at the end of the range", -> - ranges = [] - buffer.scanInRange /[ ]*/gm, [[0, 29], [1, 2]], ({range}) -> ranges.push(range) - expect(ranges).toEqual([[[0, 29], [0, 29]], [[1, 0], [1, 2]]]) - - ranges.length = 0 - buffer.scanInRange /[ ]*/gm, [[1, 0], [1, 2]], ({range}) -> ranges.push(range) - expect(ranges).toEqual([[[1, 0], [1, 2]]]) - - ranges.length = 0 - buffer.scanInRange /\s*/gm, [[0, 29], [1, 2]], ({range}) -> ranges.push(range) - expect(ranges).toEqual([[[0, 29], [1, 2]]]) - - ranges.length = 0 - buffer.scanInRange /\s*/gm, [[1, 0], [1, 2]], ({range}) -> ranges.push(range) - expect(ranges).toEqual([[[1, 0], [1, 2]]]) - - it "allows empty matches at the end of a range, when the range ends at column 0", -> - ranges = [] - buffer.scanInRange /^[ ]*/gm, [[9, 0], [10, 0]], ({range}) -> ranges.push(range) - expect(ranges).toEqual([[[9, 0], [9, 2]], [[10, 0], [10, 0]]]) - - ranges.length = 0 - buffer.scanInRange /^[ ]*/gm, [[10, 0], [10, 0]], ({range}) -> ranges.push(range) - expect(ranges).toEqual([[[10, 0], [10, 0]]]) - - ranges.length = 0 - buffer.scanInRange /^\s*/gm, [[9, 0], [10, 0]], ({range}) -> ranges.push(range) - expect(ranges).toEqual([[[9, 0], [9, 2]], [[10, 0], [10, 0]]]) - - ranges.length = 0 - buffer.scanInRange /^\s*/gm, [[10, 0], [10, 0]], ({range}) -> ranges.push(range) - expect(ranges).toEqual([[[10, 0], [10, 0]]]) - - ranges.length = 0 - buffer.scanInRange /^\s*/gm, [[11, 0], [12, 0]], ({range}) -> ranges.push(range) - expect(ranges).toEqual([[[11, 0], [11, 2]], [[12, 0], [12, 0]]]) - - it "handles multi-line patterns", -> - matchStrings = [] - - # The '\s' character class - buffer.scan /{\s+var/, ({matchText}) -> matchStrings.push(matchText) - expect(matchStrings).toEqual(['{\n var']) - - # A literal newline character - matchStrings.length = 0 - buffer.scan RegExp("{\n var"), ({matchText}) -> matchStrings.push(matchText) - expect(matchStrings).toEqual(['{\n var']) - - # A '\n' escape sequence - matchStrings.length = 0 - buffer.scan /{\n var/, ({matchText}) -> matchStrings.push(matchText) - expect(matchStrings).toEqual(['{\n var']) - - # A negated character class in the middle of the pattern - matchStrings.length = 0 - buffer.scan /{[^a] var/, ({matchText}) -> matchStrings.push(matchText) - expect(matchStrings).toEqual(['{\n var']) - - # A negated character class at the beginning of the pattern - matchStrings.length = 0 - buffer.scan /[^a] var/, ({matchText}) -> matchStrings.push(matchText) - expect(matchStrings).toEqual(['\n var']) - - describe "::find(regex)", -> - it "resolves with the first range that matches the given regex", (done) -> - buffer = new TextBuffer('abc\ndefghi') - buffer.find(/\wf\w*/).then (range) -> - expect(range).toEqual(Range(Point(1, 1), Point(1, 6))) - done() - - describe "::findAllSync(regex)", -> - it "returns all the ranges that match the given regex", -> - buffer = new TextBuffer('abc\ndefghi') - expect(buffer.findAllSync(/[bf]\w+/)).toEqual([ - Range(Point(0, 1), Point(0, 3)), - Range(Point(1, 2), Point(1, 6)), - ]) - - describe "::findAndMarkAllInRangeSync(markerLayer, regex, range, options)", -> - it "populates the marker index with the matching ranges", -> - buffer = new TextBuffer('abc def\nghi jkl\n') - layer = buffer.addMarkerLayer() - markers = buffer.findAndMarkAllInRangeSync(layer, /\w+/g, [[0, 1], [1, 6]], {invalidate: 'inside'}) - expect(markers.map((marker) -> marker.getRange())).toEqual([ - [[0, 1], [0, 3]], - [[0, 4], [0, 7]], - [[1, 0], [1, 3]], - [[1, 4], [1, 6]] - ]) - expect(markers[0].getInvalidationStrategy()).toBe('inside') - expect(markers[0].isExclusive()).toBe(true) - - markers = buffer.findAndMarkAllInRangeSync(layer, /abc/g, [[0, 0], [1, 0]], {invalidate: 'touch'}) - expect(markers.map((marker) -> marker.getRange())).toEqual([ - [[0, 0], [0, 3]] - ]) - expect(markers[0].getInvalidationStrategy()).toBe('touch') - expect(markers[0].isExclusive()).toBe(false) - - describe "::findWordsWithSubsequence and ::findWordsWithSubsequenceInRange", -> - it 'resolves with all words matching the given query', (done) -> - buffer = new TextBuffer('banana bandana ban_ana bandaid band bNa\nbanana') - buffer.findWordsWithSubsequence('bna', '_', 4).then (results) -> - expect(JSON.parse(JSON.stringify(results))).toEqual([ - { - score: 29, - matchIndices: [0, 1, 2], - positions: [{row: 0, column: 36}], - word: "bNa" - }, - { - score: 16, - matchIndices: [0, 2, 4], - positions: [{row: 0, column: 15}], - word: "ban_ana" - }, - { - score: 12, - matchIndices: [0, 2, 3], - positions: [{row: 0, column: 0}, {row: 1, column: 0}], - word: "banana" - }, - { - score: 7, - matchIndices: [0, 5, 6], - positions: [{row: 0, column: 7}], - word: "bandana" - } - ]) - done() - - it 'resolves with all words matching the given query and range', (done) -> - range = {start: {column: 0, row: 0}, end: {column: 22, row: 0}} - buffer = new TextBuffer('banana bandana ban_ana bandaid band bNa\nbanana') - buffer.findWordsWithSubsequenceInRange('bna', '_', 3, range).then (results) -> - expect(JSON.parse(JSON.stringify(results))).toEqual([ - { - score: 16, - matchIndices: [0, 2, 4], - positions: [{row: 0, column: 15}], - word: "ban_ana" - }, - { - score: 12, - matchIndices: [0, 2, 3], - positions: [{row: 0, column: 0}], - word: "banana" - }, - { - score: 7, - matchIndices: [0, 5, 6], - positions: [{row: 0, column: 7}], - word: "bandana" - } - ]) - done() - - describe "::backwardsScanInRange(range, regex, fn)", -> - beforeEach -> - filePath = require.resolve('./fixtures/sample.js') - buffer = TextBuffer.loadSync(filePath) - - describe "when given a regex with no global flag", -> - it "calls the iterator with the last match for the given regex in the given range", -> - matches = [] - ranges = [] - buffer.backwardsScanInRange /cu(rr)ent/, [[4, 0], [6, 44]], ({match, range}) -> - matches.push(match) - ranges.push(range) - - expect(matches.length).toBe 1 - expect(ranges.length).toBe 1 - - expect(matches[0][0]).toBe 'current' - expect(matches[0][1]).toBe 'rr' - expect(ranges[0]).toEqual [[6, 34], [6, 41]] - - describe "when given a regex with a global flag", -> - it "calls the iterator with each match for the given regex in the given range, starting with the last match", -> - matches = [] - ranges = [] - buffer.backwardsScanInRange /cu(rr)ent/g, [[4, 0], [6, 59]], ({match, range}) -> - matches.push(match) - ranges.push(range) - - expect(matches.length).toBe 3 - expect(ranges.length).toBe 3 - - expect(matches[0][0]).toBe 'current' - expect(matches[0][1]).toBe 'rr' - expect(ranges[0]).toEqual [[6, 34], [6, 41]] - - expect(matches[1][0]).toBe 'current' - expect(matches[1][1]).toBe 'rr' - expect(ranges[1]).toEqual [[6, 6], [6, 13]] - - expect(matches[2][0]).toBe 'current' - expect(matches[2][1]).toBe 'rr' - expect(ranges[2]).toEqual [[5, 6], [5, 13]] - - describe "when the last regex match starts at the beginning of the range", -> - it "calls the iterator with the match", -> - matches = [] - ranges = [] - buffer.scanInRange /quick/g, [[0, 4], [2, 0]], ({match, range}) -> - matches.push(match) - ranges.push(range) - - expect(matches.length).toBe 1 - expect(ranges.length).toBe 1 - - expect(matches[0][0]).toBe 'quick' - expect(ranges[0]).toEqual [[0, 4], [0, 9]] - - matches = [] - ranges = [] - buffer.scanInRange /^/, [[0, 0], [2, 0]], ({match, range}) -> - matches.push(match) - ranges.push(range) - - expect(matches.length).toBe 1 - expect(ranges.length).toBe 1 - - expect(matches[0][0]).toBe "" - expect(ranges[0]).toEqual [[0, 0], [0, 0]] - - describe "when the first regex match exceeds the end of the range", -> - describe "when the portion of the match within the range also matches the regex", -> - it "calls the iterator with the truncated match", -> - matches = [] - ranges = [] - buffer.backwardsScanInRange /cu(r*)/g, [[4, 0], [6, 9]], ({match, range}) -> - matches.push(match) - ranges.push(range) - - expect(matches.length).toBe 2 - expect(ranges.length).toBe 2 - - expect(matches[0][0]).toBe 'cur' - expect(matches[0][1]).toBe 'r' - expect(ranges[0]).toEqual [[6, 6], [6, 9]] - - expect(matches[1][0]).toBe 'curr' - expect(matches[1][1]).toBe 'rr' - expect(ranges[1]).toEqual [[5, 6], [5, 10]] - - describe "when the portion of the match within the range does not matches the regex", -> - it "does not call the iterator with the truncated match", -> - matches = [] - ranges = [] - buffer.backwardsScanInRange /cu(r*)e/g, [[4, 0], [6, 9]], ({match, range}) -> - matches.push(match) - ranges.push(range) - - expect(matches.length).toBe 1 - expect(ranges.length).toBe 1 - - expect(matches[0][0]).toBe 'curre' - expect(matches[0][1]).toBe 'rr' - expect(ranges[0]).toEqual [[5, 6], [5, 11]] - - describe "when the iterator calls the 'replace' control function with a replacement string", -> - it "replaces each occurrence of the regex match with the string", -> - ranges = [] - buffer.backwardsScanInRange /cu(rr)ent/g, [[4, 0], [6, 59]], ({range, replace}) -> - ranges.push(range) - replace("foo") unless range.start.isEqual([6, 6]) - - expect(ranges[0]).toEqual [[6, 34], [6, 41]] - expect(ranges[1]).toEqual [[6, 6], [6, 13]] - expect(ranges[2]).toEqual [[5, 6], [5, 13]] - - expect(buffer.lineForRow(5)).toBe ' foo = items.shift();' - expect(buffer.lineForRow(6)).toBe ' current < pivot ? left.push(foo) : right.push(current);' - - describe "when the iterator calls the 'stop' control function", -> - it "stops the traversal", -> - ranges = [] - buffer.backwardsScanInRange /cu(rr)ent/g, [[4, 0], [6, 59]], ({range, stop}) -> - ranges.push(range) - stop() if ranges.length is 2 - - expect(ranges.length).toBe 2 - expect(ranges[0]).toEqual [[6, 34], [6, 41]] - expect(ranges[1]).toEqual [[6, 6], [6, 13]] - - describe "when called with a random range", -> - it "returns the same results as ::scanInRange, but in the opposite order", -> - for i in [1...50] - seed = Date.now() - random = new Random(seed) - - buffer.backwardsScanChunkSize = random.intBetween(1, 80) - - [startRow, endRow] = [random(buffer.getLineCount()), random(buffer.getLineCount())].sort() - startColumn = random(buffer.lineForRow(startRow).length) - endColumn = random(buffer.lineForRow(endRow).length) - range = [[startRow, startColumn], [endRow, endColumn]] - - regex = [ - /\w/g - /\w{2}/g - /\w{3}/g - /.{5}/g - ][random(4)] - - if random(2) > 0 - forwardRanges = [] - backwardRanges = [] - forwardMatches = [] - backwardMatches = [] - - buffer.scanInRange regex, range, ({range, matchText}) -> - forwardMatches.push(matchText) - forwardRanges.push(range) - - buffer.backwardsScanInRange regex, range, ({range, matchText}) -> - backwardMatches.unshift(matchText) - backwardRanges.unshift(range) - - expect(backwardRanges).toEqual(forwardRanges, "Seed: #{seed}") - expect(backwardMatches).toEqual(forwardMatches, "Seed: #{seed}") - else - referenceBuffer = new TextBuffer(text: buffer.getText()) - referenceBuffer.scanInRange regex, range, ({matchText, replace}) -> - replace(matchText + '.') - - buffer.backwardsScanInRange regex, range, ({matchText, replace}) -> - replace(matchText + '.') - - expect(buffer.getText()).toBe(referenceBuffer.getText(), "Seed: #{seed}") - - it "does not return empty matches at the end of the range", -> - ranges = [] - - buffer.backwardsScanInRange /[ ]*/gm, [[1, 0], [1, 2]], ({range}) -> ranges.push(range) - expect(ranges).toEqual([[[1, 0], [1, 2]]]) - - ranges.length = 0 - buffer.backwardsScanInRange /[ ]*/m, [[0, 29], [1, 2]], ({range}) -> ranges.push(range) - expect(ranges).toEqual([[[1, 0], [1, 2]]]) - - ranges.length = 0 - buffer.backwardsScanInRange /\s*/gm, [[1, 0], [1, 2]], ({range}) -> ranges.push(range) - expect(ranges).toEqual([[[1, 0], [1, 2]]]) - - ranges.length = 0 - buffer.backwardsScanInRange /\s*/m, [[0, 29], [1, 2]], ({range}) -> ranges.push(range) - expect(ranges).toEqual([[[0, 29], [1, 2]]]) - - it "allows empty matches at the end of a range, when the range ends at column 0", -> - ranges = [] - buffer.backwardsScanInRange /^[ ]*/gm, [[9, 0], [10, 0]], ({range}) -> ranges.push(range) - expect(ranges).toEqual([[[10, 0], [10, 0]], [[9, 0], [9, 2]]]) - - ranges.length = 0 - buffer.backwardsScanInRange /^[ ]*/gm, [[10, 0], [10, 0]], ({range}) -> ranges.push(range) - expect(ranges).toEqual([[[10, 0], [10, 0]]]) - - ranges.length = 0 - buffer.backwardsScanInRange /^\s*/gm, [[9, 0], [10, 0]], ({range}) -> ranges.push(range) - expect(ranges).toEqual([[[10, 0], [10, 0]], [[9, 0], [9, 2]]]) - - ranges.length = 0 - buffer.backwardsScanInRange /^\s*/gm, [[10, 0], [10, 0]], ({range}) -> ranges.push(range) - expect(ranges).toEqual([[[10, 0], [10, 0]]]) - - describe "::characterIndexForPosition(position)", -> - beforeEach -> - filePath = require.resolve('./fixtures/sample.js') - buffer = TextBuffer.loadSync(filePath) - - it "returns the total number of characters that precede the given position", -> - expect(buffer.characterIndexForPosition([0, 0])).toBe 0 - expect(buffer.characterIndexForPosition([0, 1])).toBe 1 - expect(buffer.characterIndexForPosition([0, 29])).toBe 29 - expect(buffer.characterIndexForPosition([1, 0])).toBe 30 - expect(buffer.characterIndexForPosition([2, 0])).toBe 61 - expect(buffer.characterIndexForPosition([12, 2])).toBe 408 - expect(buffer.characterIndexForPosition([Infinity])).toBe 408 - - describe "when the buffer contains crlf line endings", -> - it "returns the total number of characters that precede the given position", -> - buffer.setText("line1\r\nline2\nline3\r\nline4") - expect(buffer.characterIndexForPosition([1])).toBe 7 - expect(buffer.characterIndexForPosition([2])).toBe 13 - expect(buffer.characterIndexForPosition([3])).toBe 20 - - describe "::positionForCharacterIndex(position)", -> - beforeEach -> - filePath = require.resolve('./fixtures/sample.js') - buffer = TextBuffer.loadSync(filePath) - - it "returns the position based on character index", -> - expect(buffer.positionForCharacterIndex(0)).toEqual [0, 0] - expect(buffer.positionForCharacterIndex(1)).toEqual [0, 1] - expect(buffer.positionForCharacterIndex(29)).toEqual [0, 29] - expect(buffer.positionForCharacterIndex(30)).toEqual [1, 0] - expect(buffer.positionForCharacterIndex(61)).toEqual [2, 0] - expect(buffer.positionForCharacterIndex(408)).toEqual [12, 2] - - describe "when the buffer contains crlf line endings", -> - it "returns the position based on character index", -> - buffer.setText("line1\r\nline2\nline3\r\nline4") - expect(buffer.positionForCharacterIndex(7)).toEqual [1, 0] - expect(buffer.positionForCharacterIndex(13)).toEqual [2, 0] - expect(buffer.positionForCharacterIndex(20)).toEqual [3, 0] - - describe "::isEmpty()", -> - beforeEach -> - filePath = require.resolve('./fixtures/sample.js') - buffer = TextBuffer.loadSync(filePath) - - it "returns true for an empty buffer", -> - buffer.setText('') - expect(buffer.isEmpty()).toBeTruthy() - - it "returns false for a non-empty buffer", -> - buffer.setText('a') - expect(buffer.isEmpty()).toBeFalsy() - buffer.setText('a\nb\nc') - expect(buffer.isEmpty()).toBeFalsy() - buffer.setText('\n') - expect(buffer.isEmpty()).toBeFalsy() - - describe "::hasAstral()", -> - it "returns true for buffers containing surrogate pairs", -> - expect(new TextBuffer('hooray 😄').hasAstral()).toBeTruthy() - - it "returns false for buffers that do not contain surrogate pairs", -> - expect(new TextBuffer('nope').hasAstral()).toBeFalsy() - - describe "::onWillChange(callback)", -> - it "notifies observers before a transaction, an undo or a redo", -> - changeCount = 0 - expectedText = '' - - buffer = new TextBuffer() - checkpoint = buffer.createCheckpoint() - - buffer.onWillChange (change) -> - expect(buffer.getText()).toBe expectedText - changeCount++ - - buffer.append('a') - expect(changeCount).toBe(1) - expectedText = 'a' - - buffer.transact -> - buffer.append('b') - buffer.append('c') - expect(changeCount).toBe(2) - expectedText = 'abc' - - # Empty transactions do not cause onWillChange listeners to be called - buffer.transact -> - expect(changeCount).toBe(2) - - buffer.undo() - expect(changeCount).toBe(3) - expectedText = 'a' - - buffer.redo() - expect(changeCount).toBe(4) - expectedText = 'abc' - - buffer.revertToCheckpoint(checkpoint) - expect(changeCount).toBe(5) - - describe "::onDidChange(callback)", -> - beforeEach -> - filePath = require.resolve('./fixtures/sample.js') - buffer = TextBuffer.loadSync(filePath) - - it "notifies observers after a transaction, an undo or a redo", -> - textChanges = [] - buffer.onDidChange ({changes}) -> textChanges.push(changes...) - - buffer.insert([0, 0], "abc") - buffer.delete([[0, 0], [0, 1]]) - - assertChangesEqual(textChanges, [ - { - oldRange: [[0, 0], [0, 0]], - newRange: [[0, 0], [0, 3]] - oldText: "", - newText: "abc" - }, - { - oldRange: [[0, 0], [0, 1]], - newRange: [[0, 0], [0, 0]], - oldText: "a", - newText: "" - } - ]) - - textChanges = [] - buffer.transact -> - buffer.insert([1, 0], "v") - buffer.insert([1, 1], "x") - buffer.insert([1, 2], "y") - buffer.insert([2, 3], "zw") - buffer.delete([[2, 3], [2, 4]]) - - assertChangesEqual(textChanges, [ - { - oldRange: [[1, 0], [1, 0]], - newRange: [[1, 0], [1, 3]], - oldText: "", - newText: "vxy", - }, - { - oldRange: [[2, 3], [2, 3]], - newRange: [[2, 3], [2, 4]], - oldText: "", - newText: "w", - } - ]) - - textChanges = [] - buffer.undo() - assertChangesEqual(textChanges, [ - { - oldRange: [[1, 0], [1, 3]], - newRange: [[1, 0], [1, 0]], - oldText: "vxy", - newText: "", - }, - { - oldRange: [[2, 3], [2, 4]], - newRange: [[2, 3], [2, 3]], - oldText: "w", - newText: "", - } - ]) - - textChanges = [] - buffer.redo() - assertChangesEqual(textChanges, [ - { - oldRange: [[1, 0], [1, 0]], - newRange: [[1, 0], [1, 3]], - oldText: "", - newText: "vxy", - }, - { - oldRange: [[2, 3], [2, 3]], - newRange: [[2, 3], [2, 4]], - oldText: "", - newText: "w", - } - ]) - - textChanges = [] - buffer.transact -> - buffer.transact -> - buffer.insert([0, 0], "j") - - # we emit only one event for nested transactions - assertChangesEqual(textChanges, [ - { - oldRange: [[0, 0], [0, 0]], - newRange: [[0, 0], [0, 1]], - oldText: "", - newText: "j", - } - ]) - - it "doesn't notify observers after an empty transaction", -> - didChangeTextSpy = jasmine.createSpy() - buffer.onDidChange(didChangeTextSpy) - buffer.transact(->) - expect(didChangeTextSpy).not.toHaveBeenCalled() - - it "doesn't throw an error when clearing the undo stack within a transaction", -> - buffer.onDidChange(didChangeTextSpy = jasmine.createSpy()) - expect(-> buffer.transact(-> buffer.clearUndoStack())).not.toThrowError() - expect(didChangeTextSpy).not.toHaveBeenCalled() - - describe "::onDidStopChanging(callback)", -> - [delay, didStopChangingCallback] = [] - - wait = (milliseconds, callback) -> setTimeout(callback, milliseconds) - - beforeEach -> - filePath = require.resolve('./fixtures/sample.js') - buffer = TextBuffer.loadSync(filePath) - delay = buffer.stoppedChangingDelay - didStopChangingCallback = jasmine.createSpy("didStopChangingCallback") - buffer.onDidStopChanging didStopChangingCallback - - it "notifies observers after a delay passes following changes", (done) -> - buffer.insert([0, 0], 'a') - expect(didStopChangingCallback).not.toHaveBeenCalled() - - wait delay / 2, -> - buffer.transact -> - buffer.transact -> - buffer.insert([0, 0], 'b') - buffer.insert([1, 0], 'c') - buffer.insert([1, 1], 'd') - expect(didStopChangingCallback).not.toHaveBeenCalled() - - wait delay / 2, -> - expect(didStopChangingCallback).not.toHaveBeenCalled() - - wait delay, -> - expect(didStopChangingCallback).toHaveBeenCalled() - assertChangesEqual(didStopChangingCallback.calls.mostRecent().args[0].changes, [ - { - oldRange: [[0, 0], [0, 0]], - newRange: [[0, 0], [0, 2]], - oldText: "", - newText: "ba", - }, - { - oldRange: [[1, 0], [1, 0]], - newRange: [[1, 0], [1, 2]], - oldText: "", - newText: "cd", - } - ]) - - didStopChangingCallback.calls.reset() - buffer.undo() - buffer.undo() - wait delay * 2, -> - expect(didStopChangingCallback).toHaveBeenCalled() - assertChangesEqual(didStopChangingCallback.calls.mostRecent().args[0].changes, [ - { - oldRange: [[0, 0], [0, 2]], - newRange: [[0, 0], [0, 0]], - oldText: "ba", - newText: "", - }, - { - oldRange: [[1, 0], [1, 2]], - newRange: [[1, 0], [1, 0]], - oldText: "cd", - newText: "", - }, - ]) - done() - - it "provides the correct changes when the buffer is mutated in the onDidChange callback", (done) -> - buffer.onDidChange ({changes}) -> - switch changes[0].newText - when 'a' - buffer.insert(changes[0].newRange.end, 'b') - when 'b' - buffer.insert(changes[0].newRange.end, 'c') - when 'c' - buffer.insert(changes[0].newRange.end, 'd') - - buffer.insert([0, 0], 'a') - - wait delay * 2, -> - expect(didStopChangingCallback).toHaveBeenCalled() - assertChangesEqual(didStopChangingCallback.calls.mostRecent().args[0].changes, [ - { - oldRange: [[0, 0], [0, 0]], - newRange: [[0, 0], [0, 4]], - oldText: "", - newText: "abcd", - } - ]) - done() - - describe "::append(text)", -> - beforeEach -> - filePath = require.resolve('./fixtures/sample.js') - buffer = TextBuffer.loadSync(filePath) - - it "adds text to the end of the buffer", -> - buffer.setText("") - buffer.append("a") - expect(buffer.getText()).toBe "a" - buffer.append("b\nc") - expect(buffer.getText()).toBe "ab\nc" - - describe "::setLanguageMode", -> - it "destroys the previous language mode", -> - buffer = new TextBuffer() - - languageMode1 = { - alive: true, - destroy: -> @alive = false - onDidChangeHighlighting: -> {dispose: ->} - } - - languageMode2 = { - alive: true, - destroy: -> @alive = false - onDidChangeHighlighting: -> {dispose: ->} - } - - buffer.setLanguageMode(languageMode1) - expect(languageMode1.alive).toBe(true) - expect(languageMode2.alive).toBe(true) - - buffer.setLanguageMode(languageMode2) - expect(languageMode1.alive).toBe(false) - expect(languageMode2.alive).toBe(true) - - buffer.destroy() - expect(languageMode1.alive).toBe(false) - expect(languageMode2.alive).toBe(false) - - it "notifies ::onDidChangeLanguageMode observers when the language mode changes", -> - buffer = new TextBuffer() - expect(buffer.getLanguageMode() instanceof NullLanguageMode).toBe(true) - - events = [] - buffer.onDidChangeLanguageMode (newMode, oldMode) -> events.push({newMode: newMode, oldMode: oldMode}) - - languageMode = { - onDidChangeHighlighting: -> {dispose: ->} - } - - buffer.setLanguageMode(languageMode) - expect(buffer.getLanguageMode()).toBe(languageMode) - expect(events.length).toBe(1) - expect(events[0].newMode).toBe(languageMode) - expect(events[0].oldMode instanceof NullLanguageMode).toBe(true) - - buffer.setLanguageMode(null) - expect(buffer.getLanguageMode() instanceof NullLanguageMode).toBe(true) - expect(events.length).toBe(2) - expect(events[1].newMode).toBe(buffer.getLanguageMode()) - expect(events[1].oldMode).toBe(languageMode) - - describe "line ending support", -> - beforeEach -> - filePath = require.resolve('./fixtures/sample.js') - buffer = TextBuffer.loadSync(filePath) - - describe ".getText()", -> - it "returns the text with the corrent line endings for each row", -> - buffer.setText("a\r\nb\nc") - expect(buffer.getText()).toBe "a\r\nb\nc" - buffer.setText("a\r\nb\nc\n") - expect(buffer.getText()).toBe "a\r\nb\nc\n" - - describe "when editing a line", -> - it "preserves the existing line ending", -> - buffer.setText("a\r\nb\nc") - buffer.insert([0, 1], "1") - expect(buffer.getText()).toBe "a1\r\nb\nc" - - describe "when inserting text with multiple lines", -> - describe "when the current line has a line ending", -> - it "uses the same line ending as the line where the text is inserted", -> - buffer.setText("a\r\n") - buffer.insert([0, 1], "hello\n1\n\n2") - expect(buffer.getText()).toBe "ahello\r\n1\r\n\r\n2\r\n" - - describe "when the current line has no line ending (because it's the last line of the buffer)", -> - describe "when the buffer contains only a single line", -> - it "honors the line endings in the inserted text", -> - buffer.setText("initialtext") - buffer.append("hello\n1\r\n2\n") - expect(buffer.getText()).toBe "initialtexthello\n1\r\n2\n" - - describe "when the buffer contains a preceding line", -> - it "uses the line ending of the preceding line", -> - buffer.setText("\ninitialtext") - buffer.append("hello\n1\r\n2\n") - expect(buffer.getText()).toBe "\ninitialtexthello\n1\n2\n" - - describe "::setPreferredLineEnding(lineEnding)", -> - it "uses the given line ending when normalizing, rather than inferring one from the surrounding text", -> - buffer = new TextBuffer(text: "a \r\n") - - expect(buffer.getPreferredLineEnding()).toBe null - buffer.append(" b \n") - expect(buffer.getText()).toBe "a \r\n b \r\n" - - buffer.setPreferredLineEnding("\n") - expect(buffer.getPreferredLineEnding()).toBe "\n" - buffer.append(" c \n") - expect(buffer.getText()).toBe "a \r\n b \r\n c \n" - - buffer.setPreferredLineEnding(null) - buffer.append(" d \r\n") - expect(buffer.getText()).toBe "a \r\n b \r\n c \n d \n" - - it "persists across serialization and deserialization", (done) -> - bufferA = new TextBuffer - bufferA.setPreferredLineEnding("\r\n") - - TextBuffer.deserialize(bufferA.serialize()).then (bufferB) -> - expect(bufferB.getPreferredLineEnding()).toBe "\r\n" - done() - -assertChangesEqual = (actualChanges, expectedChanges) -> - expect(actualChanges.length).toBe(expectedChanges.length) - for actualChange, i in actualChanges - expectedChange = expectedChanges[i] - expect(actualChange.oldRange).toEqual(expectedChange.oldRange) - expect(actualChange.newRange).toEqual(expectedChange.newRange) - expect(actualChange.oldText).toEqual(expectedChange.oldText) - expect(actualChange.newText).toEqual(expectedChange.newText) diff --git a/spec/text-buffer-spec.js b/spec/text-buffer-spec.js index 1e44cc05ac..482b2bb479 100644 --- a/spec/text-buffer-spec.js +++ b/spec/text-buffer-spec.js @@ -1,5 +1,3269 @@ -const path = require('path') -const TextBuffer = require('../src/text-buffer') +/* + * decaffeinate suggestions: + * DS205: Consider reworking code to avoid use of IIFEs + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md + */ +const fs = require('fs-plus'); +const path = require('path'); +const {join} = path; +const temp = require('temp'); +const {File} = require('@pulsar-edit/pathwatcher'); +const Random = require('random-seed'); +const Point = require('../src/point'); +const Range = require('../src/range'); +const DisplayLayer = require('../src/display-layer'); +const DefaultHistoryProvider = require('../src/default-history-provider'); +const TextBuffer = require('../src/text-buffer'); +const SampleText = fs.readFileSync(join(__dirname, 'fixtures', 'sample.js'), 'utf8'); +const {buildRandomLines, getRandomBufferRange} = require('./helpers/random'); +const NullLanguageMode = require('../src/null-language-mode'); + +describe("TextBuffer", function() { + let buffer = null; + + beforeEach(function() { + temp.track(); + jasmine.addCustomEqualityTester(require("underscore-plus").isEqual); + // When running specs in Atom, setTimeout is spied on by default. + jasmine.useRealClock?.(); + }); + + afterEach(function() { + buffer?.destroy(); + buffer = null; + }); + + describe("construction", function() { + it("can be constructed empty", function() { + buffer = new TextBuffer; + expect(buffer.getLineCount()).toBe(1); + expect(buffer.getText()).toBe(''); + expect(buffer.lineForRow(0)).toBe(''); + expect(buffer.lineEndingForRow(0)).toBe(''); + }); + + it("can be constructed with initial text containing no trailing newline", function() { + const text = "hello\nworld\r\nhow are you doing?\r\nlast"; + buffer = new TextBuffer(text); + expect(buffer.getLineCount()).toBe(4); + expect(buffer.getText()).toBe(text); + expect(buffer.lineForRow(0)).toBe('hello'); + expect(buffer.lineEndingForRow(0)).toBe('\n'); + expect(buffer.lineForRow(1)).toBe('world'); + expect(buffer.lineEndingForRow(1)).toBe('\r\n'); + expect(buffer.lineForRow(2)).toBe('how are you doing?'); + expect(buffer.lineEndingForRow(2)).toBe('\r\n'); + expect(buffer.lineForRow(3)).toBe('last'); + expect(buffer.lineEndingForRow(3)).toBe(''); + }); + + it("can be constructed with initial text containing a trailing newline", function() { + const text = "first\n"; + buffer = new TextBuffer(text); + expect(buffer.getLineCount()).toBe(2); + expect(buffer.getText()).toBe(text); + expect(buffer.lineForRow(0)).toBe('first'); + expect(buffer.lineEndingForRow(0)).toBe('\n'); + expect(buffer.lineForRow(1)).toBe(''); + expect(buffer.lineEndingForRow(1)).toBe(''); + }); + + it("automatically assigns a unique identifier to new buffers", function() { + const bufferIds = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16].map(() => new TextBuffer().getId()); + const uniqueBufferIds = new Set(bufferIds); + + expect(uniqueBufferIds.size).toBe(bufferIds.length); + }); + }); + + describe("::destroy()", () => it("clears the buffer's state", function(done) { + const filePath = temp.openSync('atom').path; + buffer = new TextBuffer(); + buffer.setPath(filePath); + buffer.append("a"); + buffer.append("b"); + buffer.destroy(); + + expect(buffer.getText()).toBe(''); + buffer.undo(); + expect(buffer.getText()).toBe(''); + buffer.save().catch(function(error) { + expect(error.message).toMatch(/Can't save destroyed buffer/); + done(); + }); + })); + + describe("::setTextInRange(range, text)", function() { + beforeEach(() => buffer = new TextBuffer("hello\nworld\r\nhow are you doing?")); + + it("can replace text on a single line with a standard newline", function() { + buffer.setTextInRange([[0, 2], [0, 4]], "y y"); + expect(buffer.getText()).toEqual("hey yo\nworld\r\nhow are you doing?"); + }); + + it("can replace text on a single line with a carriage-return/newline", function() { + buffer.setTextInRange([[1, 3], [1, 5]], "ms"); + expect(buffer.getText()).toEqual("hello\nworms\r\nhow are you doing?"); + }); + + it("can replace text in a region spanning multiple lines, ending on the last line", function() { + buffer.setTextInRange([[0, 2], [2, 3]], "y there\r\ncat\nwhat", {normalizeLineEndings: false}); + expect(buffer.getText()).toEqual("hey there\r\ncat\nwhat are you doing?"); + }); + + it("can replace text in a region spanning multiple lines, ending with a carriage-return/newline", function() { + buffer.setTextInRange([[0, 2], [1, 3]], "y\nyou're o", {normalizeLineEndings: false}); + expect(buffer.getText()).toEqual("hey\nyou're old\r\nhow are you doing?"); + }); + + describe("after a change", () => it("notifies, in order: the language mode, display layers, and display layer ::onDidChange observers with the relevant details", function() { + buffer = new TextBuffer("hello\nworld\r\nhow are you doing?"); + + const events = []; + const languageMode = { + bufferDidChange(e) { events.push({source: 'language-mode', event: e}); }, + bufferDidFinishTransaction() {}, + onDidChangeHighlighting() { return {dispose() {}}; } + }; + const displayLayer1 = buffer.addDisplayLayer(); + const displayLayer2 = buffer.addDisplayLayer(); + spyOn(displayLayer1, 'bufferDidChange').and.callFake(function(e) { + events.push({source: 'display-layer-1', event: e}); + return DisplayLayer.prototype.bufferDidChange.call(displayLayer1, e); + }); + spyOn(displayLayer2, 'bufferDidChange').and.callFake(function(e) { + events.push({source: 'display-layer-2', event: e}); + return DisplayLayer.prototype.bufferDidChange.call(displayLayer2, e); + }); + buffer.setLanguageMode(languageMode); + buffer.onDidChange(e => events.push({source: 'buffer', event: JSON.parse(JSON.stringify(e))})); + displayLayer1.onDidChange(e => events.push({source: 'display-layer-event', event: e})); + + buffer.transact(function() { + buffer.setTextInRange([[0, 2], [2, 3]], "y there\r\ncat\nwhat", {normalizeLineEndings: false}); + buffer.setTextInRange([[1, 1], [1, 2]], "abc", {normalizeLineEndings: false}); + }); + + const changeEvent1 = { + oldRange: [[0, 2], [2, 3]], newRange: [[0, 2], [2, 4]], + oldText: "llo\nworld\r\nhow", newText: "y there\r\ncat\nwhat", + }; + const changeEvent2 = { + oldRange: [[1, 1], [1, 2]], newRange: [[1, 1], [1, 4]], + oldText: "a", newText: "abc", + }; + expect(events).toEqual([ + {source: 'language-mode', event: changeEvent1}, + {source: 'display-layer-1', event: changeEvent1}, + {source: 'display-layer-2', event: changeEvent1}, + + {source: 'language-mode', event: changeEvent2}, + {source: 'display-layer-1', event: changeEvent2}, + {source: 'display-layer-2', event: changeEvent2}, + + { + source: 'buffer', + event: { + oldRange: Range(Point(0, 2), Point(2, 3)), + newRange: Range(Point(0, 2), Point(2, 4)), + changes: [ + { + oldRange: Range(Point(0, 2), Point(2, 3)), + newRange: Range(Point(0, 2), Point(2, 4)), + oldText: "llo\nworld\r\nhow", + newText: "y there\r\ncabct\nwhat" + } + ] + } + }, + { + source: 'display-layer-event', + event: [{ + oldRange: Range(Point(0, 0), Point(3, 0)), + newRange: Range(Point(0, 0), Point(3, 0)) + }] + } + ]); + })); + + it("returns the newRange of the change", () => expect(buffer.setTextInRange([[0, 2], [2, 3]], "y there\r\ncat\nwhat"), {normalizeLineEndings: false}).toEqual([[0, 2], [2, 4]])); + + it("clips the given range", function() { + buffer.setTextInRange([[-1, -1], [0, 1]], "y"); + buffer.setTextInRange([[0, 10], [0, 100]], "w"); + expect(buffer.lineForRow(0)).toBe("yellow"); + }); + + it("preserves the line endings of existing lines", function() { + buffer.setTextInRange([[0, 1], [0, 2]], 'o'); + expect(buffer.lineEndingForRow(0)).toBe('\n'); + buffer.setTextInRange([[1, 1], [1, 3]], 'i'); + expect(buffer.lineEndingForRow(1)).toBe('\r\n'); + }); + + it("freezes change event ranges", function() { + let changedOldRange = null; + let changedNewRange = null; + buffer.onDidChange(function({oldRange, newRange}) { + oldRange.start = Point(0, 3); + oldRange.start.row = 1; + newRange.start = Point(4, 4); + newRange.end.row = 2; + changedOldRange = oldRange; + changedNewRange = newRange; + }); + + buffer.setTextInRange(Range(Point(0, 2), Point(0, 4)), "y y"); + + expect(changedOldRange).toEqual([[0, 2], [0, 4]]); + expect(changedNewRange).toEqual([[0, 2], [0, 5]]); + }); + + describe("when the undo option is 'skip'", function() { + it("replaces the contents of the buffer with the given text", function() { + buffer.setTextInRange([[0, 0], [0, 1]], "y"); + buffer.setTextInRange([[0, 10], [0, 100]], "w", {undo: 'skip'}); + expect(buffer.lineForRow(0)).toBe("yellow"); + + expect(buffer.undo()).toBe(true); + expect(buffer.lineForRow(0)).toBe("hello"); + }); + + it("still emits marker change events (regression)", function() { + const markerLayer = buffer.addMarkerLayer(); + const marker = markerLayer.markRange([[0, 0], [0, 3]]); + + let markerLayerUpdateEventsCount = 0; + const markerChangeEvents = []; + markerLayer.onDidUpdate(() => markerLayerUpdateEventsCount++); + marker.onDidChange(event => markerChangeEvents.push(event)); + + buffer.setTextInRange([[0, 0], [0, 1]], '', {undo: 'skip'}); + expect(markerLayerUpdateEventsCount).toBe(1); + expect(markerChangeEvents).toEqual([{ + wasValid: true, isValid: true, + hadTail: true, hasTail: true, + oldProperties: {}, newProperties: {}, + oldHeadPosition: Point(0, 3), newHeadPosition: Point(0, 2), + oldTailPosition: Point(0, 0), newTailPosition: Point(0, 0), + textChanged: true + }]); + markerChangeEvents.length = 0; + + buffer.transact(() => buffer.setTextInRange([[0, 0], [0, 1]], '', {undo: 'skip'})); + expect(markerLayerUpdateEventsCount).toBe(2); + expect(markerChangeEvents).toEqual([{ + wasValid: true, isValid: true, + hadTail: true, hasTail: true, + oldProperties: {}, newProperties: {}, + oldHeadPosition: Point(0, 2), newHeadPosition: Point(0, 1), + oldTailPosition: Point(0, 0), newTailPosition: Point(0, 0), + textChanged: true + }]); + }); + + it("still emits text change events (regression)", function(done) { + const didChangeEvents = []; + buffer.onDidChange(event => didChangeEvents.push(event)); + + buffer.onDidStopChanging(function({changes}) { + assertChangesEqual(changes, [{ + oldRange: [[0, 0], [0, 1]], + newRange: [[0, 0], [0, 1]], + oldText: 'h', + newText: 'z' + }]); + done(); + }); + + buffer.setTextInRange([[0, 0], [0, 1]], 'y', {undo: 'skip'}); + expect(didChangeEvents.length).toBe(1); + assertChangesEqual(didChangeEvents[0].changes, [{ + oldRange: [[0, 0], [0, 1]], + newRange: [[0, 0], [0, 1]], + oldText: 'h', + newText: 'y' + }]); + + buffer.transact(() => buffer.setTextInRange([[0, 0], [0, 1]], 'z', {undo: 'skip'})); + expect(didChangeEvents.length).toBe(2); + assertChangesEqual(didChangeEvents[1].changes, [{ + oldRange: [[0, 0], [0, 1]], + newRange: [[0, 0], [0, 1]], + oldText: 'y', + newText: 'z' + }]); + }); + }); + + describe("when the normalizeLineEndings argument is true (the default)", function() { + describe("when the range's start row has a line ending", () => it("normalizes inserted line endings to match the line ending of the range's start row", function() { + const changeEvents = []; + buffer.onDidChange(e => changeEvents.push(e)); + + expect(buffer.lineEndingForRow(0)).toBe('\n'); + buffer.setTextInRange([[0, 2], [0, 5]], "y\r\nthere\r\ncrazy"); + expect(buffer.lineEndingForRow(0)).toBe('\n'); + expect(buffer.lineEndingForRow(1)).toBe('\n'); + expect(buffer.lineEndingForRow(2)).toBe('\n'); + expect(changeEvents[0].newText).toBe("y\nthere\ncrazy"); + + expect(buffer.lineEndingForRow(3)).toBe('\r\n'); + buffer.setTextInRange([[3, 3], [4, Infinity]], "ms\ndo you\r\nlike\ndirt"); + expect(buffer.lineEndingForRow(3)).toBe('\r\n'); + expect(buffer.lineEndingForRow(4)).toBe('\r\n'); + expect(buffer.lineEndingForRow(5)).toBe('\r\n'); + expect(buffer.lineEndingForRow(6)).toBe(''); + expect(changeEvents[1].newText).toBe("ms\r\ndo you\r\nlike\r\ndirt"); + + buffer.setTextInRange([[5, 1], [5, 3]], '\r'); + expect(changeEvents[2].changes).toEqual([{ + oldRange: [[5, 1], [5, 3]], + newRange: [[5, 1], [6, 0]], + oldText: 'ik', + newText: '\r\n' + }]); + + buffer.undo(); + expect(changeEvents[3].changes).toEqual([{ + oldRange: [[5, 1], [6, 0]], + newRange: [[5, 1], [5, 3]], + oldText: '\r\n', + newText: 'ik' + }]); + + buffer.redo(); + expect(changeEvents[4].changes).toEqual([{ + oldRange: [[5, 1], [5, 3]], + newRange: [[5, 1], [6, 0]], + oldText: 'ik', + newText: '\r\n' + }]); + })); + + describe("when the range's start row has no line ending (because it's the last line of the buffer)", function() { + describe("when the buffer contains no newlines", () => it("honors the newlines in the inserted text", function() { + buffer = new TextBuffer("hello"); + buffer.setTextInRange([[0, 2], [0, Infinity]], "hey\r\nthere\nworld"); + expect(buffer.lineEndingForRow(0)).toBe('\r\n'); + expect(buffer.lineEndingForRow(1)).toBe('\n'); + expect(buffer.lineEndingForRow(2)).toBe(''); + })); + + describe("when the buffer contains newlines", () => it("normalizes inserted line endings to match the line ending of the penultimate row", function() { + expect(buffer.lineEndingForRow(1)).toBe('\r\n'); + buffer.setTextInRange([[2, 0], [2, Infinity]], "what\ndo\r\nyou\nwant?"); + expect(buffer.lineEndingForRow(2)).toBe('\r\n'); + expect(buffer.lineEndingForRow(3)).toBe('\r\n'); + expect(buffer.lineEndingForRow(4)).toBe('\r\n'); + expect(buffer.lineEndingForRow(5)).toBe(''); + })); + }); + }); + + describe("when the normalizeLineEndings argument is false", () => it("honors the newlines in the inserted text", function() { + buffer.setTextInRange([[1, 0], [1, 5]], "moon\norbiting\r\nhappily\nthere", {normalizeLineEndings: false}); + expect(buffer.lineEndingForRow(1)).toBe('\n'); + expect(buffer.lineEndingForRow(2)).toBe('\r\n'); + expect(buffer.lineEndingForRow(3)).toBe('\n'); + expect(buffer.lineEndingForRow(4)).toBe('\r\n'); + expect(buffer.lineEndingForRow(5)).toBe(''); + })); + }); + + describe("::setText(text)", () => it("replaces the contents of the buffer with the given text", function() { + buffer = new TextBuffer("hello\nworld\r\nyou are cool"); + buffer.setText("goodnight\r\nmoon\nit's been good"); + expect(buffer.getText()).toBe("goodnight\r\nmoon\nit's been good"); + buffer.undo(); + expect(buffer.getText()).toBe("hello\nworld\r\nyou are cool"); + })); + + describe("::insert(position, text, normalizeNewlinesn)", function() { + it("inserts text at the given position", function() { + buffer = new TextBuffer("hello world"); + buffer.insert([0, 5], " there"); + expect(buffer.getText()).toBe("hello there world"); + }); + + it("honors the normalizeNewlines option", function() { + buffer = new TextBuffer("hello\nworld"); + buffer.insert([0, 5], "\r\nthere\r\nlittle", {normalizeLineEndings: false}); + expect(buffer.getText()).toBe("hello\r\nthere\r\nlittle\nworld"); + }); + }); + + describe("::append(text, normalizeNewlines)", function() { + it("appends text to the end of the buffer", function() { + buffer = new TextBuffer("hello world"); + buffer.append(", how are you?"); + expect(buffer.getText()).toBe("hello world, how are you?"); + }); + + it("honors the normalizeNewlines option", function() { + buffer = new TextBuffer("hello\nworld"); + buffer.append("\r\nhow\r\nare\nyou?", {normalizeLineEndings: false}); + expect(buffer.getText()).toBe("hello\nworld\r\nhow\r\nare\nyou?"); + }); + }); + + describe("::delete(range)", () => it("deletes text in the given range", function() { + buffer = new TextBuffer("hello world"); + buffer.delete([[0, 5], [0, 11]]); + expect(buffer.getText()).toBe("hello"); + })); + + describe("::deleteRows(startRow, endRow)", function() { + beforeEach(() => buffer = new TextBuffer("first\nsecond\nthird\nlast")); + + describe("when the endRow is less than the last row of the buffer", () => it("deletes the specified rows", function() { + buffer.deleteRows(1, 2); + expect(buffer.getText()).toBe("first\nlast"); + buffer.deleteRows(0, 0); + expect(buffer.getText()).toBe("last"); + })); + + describe("when the endRow is the last row of the buffer", () => it("deletes the specified rows", function() { + buffer.deleteRows(2, 3); + expect(buffer.getText()).toBe("first\nsecond"); + buffer.deleteRows(0, 1); + expect(buffer.getText()).toBe(""); + })); + + it("clips the given row range", function() { + buffer.deleteRows(-1, 0); + expect(buffer.getText()).toBe("second\nthird\nlast"); + buffer.deleteRows(1, 5); + expect(buffer.getText()).toBe("second"); + + buffer.deleteRows(-2, -1); + expect(buffer.getText()).toBe("second"); + buffer.deleteRows(1, 2); + expect(buffer.getText()).toBe("second"); + }); + + it("handles out of order row ranges", function() { + buffer.deleteRows(2, 1); + expect(buffer.getText()).toBe("first\nlast"); + }); + }); + + describe("::getText()", () => it("returns the contents of the buffer as a single string", function() { + buffer = new TextBuffer("hello\nworld\r\nhow are you?"); + expect(buffer.getText()).toBe("hello\nworld\r\nhow are you?"); + buffer.setTextInRange([[1, 0], [1, 5]], "mom"); + expect(buffer.getText()).toBe("hello\nmom\r\nhow are you?"); + })); + + describe("::undo() and ::redo()", function() { + beforeEach(() => buffer = new TextBuffer({text: "hello\nworld\r\nhow are you doing?"})); + + it("undoes and redoes multiple changes", function() { + buffer.setTextInRange([[0, 5], [0, 5]], " there"); + buffer.setTextInRange([[1, 0], [1, 5]], "friend"); + expect(buffer.getText()).toBe("hello there\nfriend\r\nhow are you doing?"); + + buffer.undo(); + expect(buffer.getText()).toBe("hello there\nworld\r\nhow are you doing?"); + + buffer.undo(); + expect(buffer.getText()).toBe("hello\nworld\r\nhow are you doing?"); + + buffer.undo(); + expect(buffer.getText()).toBe("hello\nworld\r\nhow are you doing?"); + + buffer.redo(); + expect(buffer.getText()).toBe("hello there\nworld\r\nhow are you doing?"); + + buffer.undo(); + expect(buffer.getText()).toBe("hello\nworld\r\nhow are you doing?"); + + buffer.redo(); + buffer.redo(); + expect(buffer.getText()).toBe("hello there\nfriend\r\nhow are you doing?"); + + buffer.redo(); + expect(buffer.getText()).toBe("hello there\nfriend\r\nhow are you doing?"); + }); + + it("clears the redo stack upon a fresh change", function() { + buffer.setTextInRange([[0, 5], [0, 5]], " there"); + buffer.setTextInRange([[1, 0], [1, 5]], "friend"); + expect(buffer.getText()).toBe("hello there\nfriend\r\nhow are you doing?"); + + buffer.undo(); + expect(buffer.getText()).toBe("hello there\nworld\r\nhow are you doing?"); + + buffer.setTextInRange([[1, 3], [1, 5]], "m"); + expect(buffer.getText()).toBe("hello there\nworm\r\nhow are you doing?"); + + buffer.redo(); + expect(buffer.getText()).toBe("hello there\nworm\r\nhow are you doing?"); + + buffer.undo(); + expect(buffer.getText()).toBe("hello there\nworld\r\nhow are you doing?"); + + buffer.undo(); + expect(buffer.getText()).toBe("hello\nworld\r\nhow are you doing?"); + }); + + it("does not allow the undo stack to grow without bound", function() { + buffer = new TextBuffer({maxUndoEntries: 12}); + + // Each transaction is treated as a single undo entry. We can undo up + // to 12 of them. + buffer.setText(""); + buffer.clearUndoStack(); + for (var i = 0; i < 13; i++) { + buffer.transact(function() { + buffer.append(String(i)); + buffer.append("\n"); + }); + } + expect(buffer.getLineCount()).toBe(14); + + let undoCount = 0; + while (buffer.undo()) { undoCount++; } + expect(undoCount).toBe(12); + expect(buffer.getText()).toBe('0\n'); + }); + }); + + describe("::createMarkerSnapshot", function() { + let markerLayers = null; + + beforeEach(function() { + buffer = new TextBuffer; + + markerLayers = [ + buffer.addMarkerLayer({maintainHistory: true, role: "selections"}), + buffer.addMarkerLayer({maintainHistory: true}), + buffer.addMarkerLayer({maintainHistory: true, role: "selections"}), + buffer.addMarkerLayer({maintainHistory: true}) + ];}); + + describe("when selectionsMarkerLayer is not passed", () => it("takes a snapshot of all markerLayers", function() { + const snapshot = buffer.createMarkerSnapshot(); + const markerLayerIdsInSnapshot = Object.keys(snapshot); + expect(markerLayerIdsInSnapshot.length).toBe(4); + expect(markerLayerIdsInSnapshot.includes(markerLayers[0].id)).toBe(true); + expect(markerLayerIdsInSnapshot.includes(markerLayers[1].id)).toBe(true); + expect(markerLayerIdsInSnapshot.includes(markerLayers[2].id)).toBe(true); + expect(markerLayerIdsInSnapshot.includes(markerLayers[3].id)).toBe(true); + })); + + describe("when selectionsMarkerLayer is passed", () => it("skips snapshotting of other 'selection' role marker layers", function() { + let snapshot = buffer.createMarkerSnapshot(markerLayers[0]); + let markerLayerIdsInSnapshot = Object.keys(snapshot); + expect(markerLayerIdsInSnapshot.length).toBe(3); + expect(markerLayerIdsInSnapshot.includes(markerLayers[0].id)).toBe(true); + expect(markerLayerIdsInSnapshot.includes(markerLayers[1].id)).toBe(true); + expect(markerLayerIdsInSnapshot.includes(markerLayers[2].id)).toBe(false); + expect(markerLayerIdsInSnapshot.includes(markerLayers[3].id)).toBe(true); + + snapshot = buffer.createMarkerSnapshot(markerLayers[2]); + markerLayerIdsInSnapshot = Object.keys(snapshot); + expect(markerLayerIdsInSnapshot.length).toBe(3); + expect(markerLayerIdsInSnapshot.includes(markerLayers[0].id)).toBe(false); + expect(markerLayerIdsInSnapshot.includes(markerLayers[1].id)).toBe(true); + expect(markerLayerIdsInSnapshot.includes(markerLayers[2].id)).toBe(true); + expect(markerLayerIdsInSnapshot.includes(markerLayers[3].id)).toBe(true); + })); + }); + + describe("selective snapshotting and restoration on transact/undo/redo for selections marker layer", function() { + let markerLayers, marker0, marker1, marker2, textUndo, textRedo, rangesBefore, rangesAfter; + const ensureMarkerLayer = function(markerLayer, range) { + const markers = markerLayer.findMarkers({}); + expect(markers.length).toBe(1); + expect(markers[0].getRange()).toEqual(range); + }; + + const getFirstMarker = markerLayer => markerLayer.findMarkers({})[0]; + + beforeEach(function() { + buffer = new TextBuffer({text: "00000000\n11111111\n22222222\n33333333\n"}); + + markerLayers = [ + buffer.addMarkerLayer({maintainHistory: true, role: "selections"}), + buffer.addMarkerLayer({maintainHistory: true, role: "selections"}), + buffer.addMarkerLayer({maintainHistory: true, role: "selections"}) + ]; + + textUndo = "00000000\n11111111\n22222222\n33333333\n"; + textRedo = "00000000\n11111111\n22222222\n33333333\n44444444\n"; + + rangesBefore = [ + [[0, 1], [0, 1]], + [[0, 2], [0, 2]], + [[0, 3], [0, 3]] + ]; + rangesAfter = [ + [[2, 1], [2, 1]], + [[2, 2], [2, 2]], + [[2, 3], [2, 3]] + ]; + + marker0 = markerLayers[0].markRange(rangesBefore[0]); + marker1 = markerLayers[1].markRange(rangesBefore[1]); + marker2 = markerLayers[2].markRange(rangesBefore[2]); + }); + + it("restores a snapshot from other selections marker layers on undo/redo", function() { + // Snapshot is taken for markerLayers[0] only, markerLayer[1] and markerLayer[2] are skipped + buffer.transact({selectionsMarkerLayer: markerLayers[0]}, function() { + buffer.append("44444444\n"); + marker0.setRange(rangesAfter[0]); + marker1.setRange(rangesAfter[1]); + marker2.setRange(rangesAfter[2]); + }); + + buffer.undo({selectionsMarkerLayer: markerLayers[0]}); + expect(buffer.getText()).toBe(textUndo); + + ensureMarkerLayer(markerLayers[0], rangesBefore[0]); + ensureMarkerLayer(markerLayers[1], rangesAfter[1]); + ensureMarkerLayer(markerLayers[2], rangesAfter[2]); + expect(getFirstMarker(markerLayers[0])).toBe(marker0); + expect(getFirstMarker(markerLayers[1])).toBe(marker1); + expect(getFirstMarker(markerLayers[2])).toBe(marker2); + + buffer.redo({selectionsMarkerLayer: markerLayers[0]}); + expect(buffer.getText()).toBe(textRedo); + + ensureMarkerLayer(markerLayers[0], rangesAfter[0]); + ensureMarkerLayer(markerLayers[1], rangesAfter[1]); + ensureMarkerLayer(markerLayers[2], rangesAfter[2]); + expect(getFirstMarker(markerLayers[0])).toBe(marker0); + expect(getFirstMarker(markerLayers[1])).toBe(marker1); + expect(getFirstMarker(markerLayers[2])).toBe(marker2); + + buffer.undo({selectionsMarkerLayer: markerLayers[1]}); + expect(buffer.getText()).toBe(textUndo); + + ensureMarkerLayer(markerLayers[0], rangesAfter[0]); + ensureMarkerLayer(markerLayers[1], rangesBefore[0]); + ensureMarkerLayer(markerLayers[2], rangesAfter[2]); + expect(getFirstMarker(markerLayers[0])).toBe(marker0); + expect(getFirstMarker(markerLayers[1])).not.toBe(marker1); + expect(getFirstMarker(markerLayers[2])).toBe(marker2); + expect(marker1.isDestroyed()).toBe(true); + + buffer.redo({selectionsMarkerLayer: markerLayers[2]}); + expect(buffer.getText()).toBe(textRedo); + + ensureMarkerLayer(markerLayers[0], rangesAfter[0]); + ensureMarkerLayer(markerLayers[1], rangesBefore[0]); + ensureMarkerLayer(markerLayers[2], rangesAfter[0]); + expect(getFirstMarker(markerLayers[0])).toBe(marker0); + expect(getFirstMarker(markerLayers[1])).not.toBe(marker1); + expect(getFirstMarker(markerLayers[2])).not.toBe(marker2); + expect(marker1.isDestroyed()).toBe(true); + expect(marker2.isDestroyed()).toBe(true); + + buffer.undo({selectionsMarkerLayer: markerLayers[2]}); + expect(buffer.getText()).toBe(textUndo); + + ensureMarkerLayer(markerLayers[0], rangesAfter[0]); + ensureMarkerLayer(markerLayers[1], rangesBefore[0]); + ensureMarkerLayer(markerLayers[2], rangesBefore[0]); + expect(getFirstMarker(markerLayers[0])).toBe(marker0); + expect(getFirstMarker(markerLayers[1])).not.toBe(marker1); + expect(getFirstMarker(markerLayers[2])).not.toBe(marker2); + expect(marker1.isDestroyed()).toBe(true); + expect(marker2.isDestroyed()).toBe(true); + }); + + it("can restore a snapshot taken at a destroyed selections marker layer given selectionsMarkerLayer", function() { + buffer.transact({selectionsMarkerLayer: markerLayers[1]}, function() { + buffer.append("44444444\n"); + marker0.setRange(rangesAfter[0]); + marker1.setRange(rangesAfter[1]); + marker2.setRange(rangesAfter[2]); + }); + + markerLayers[1].destroy(); + expect(buffer.getMarkerLayer(markerLayers[0].id)).toBeTruthy(); + expect(buffer.getMarkerLayer(markerLayers[1].id)).toBeFalsy(); + expect(buffer.getMarkerLayer(markerLayers[2].id)).toBeTruthy(); + expect(marker0.isDestroyed()).toBe(false); + expect(marker1.isDestroyed()).toBe(true); + expect(marker2.isDestroyed()).toBe(false); + + buffer.undo({selectionsMarkerLayer: markerLayers[0]}); + expect(buffer.getText()).toBe(textUndo); + + ensureMarkerLayer(markerLayers[0], rangesBefore[1]); + ensureMarkerLayer(markerLayers[2], rangesAfter[2]); + expect(marker0.isDestroyed()).toBe(true); + expect(marker2.isDestroyed()).toBe(false); + + buffer.redo({selectionsMarkerLayer: markerLayers[0]}); + expect(buffer.getText()).toBe(textRedo); + ensureMarkerLayer(markerLayers[0], rangesAfter[1]); + ensureMarkerLayer(markerLayers[2], rangesAfter[2]); + + markerLayers[3] = markerLayers[2].copy(); + ensureMarkerLayer(markerLayers[3], rangesAfter[2]); + markerLayers[0].destroy(); + markerLayers[2].destroy(); + expect(buffer.getMarkerLayer(markerLayers[0].id)).toBeFalsy(); + expect(buffer.getMarkerLayer(markerLayers[1].id)).toBeFalsy(); + expect(buffer.getMarkerLayer(markerLayers[2].id)).toBeFalsy(); + expect(buffer.getMarkerLayer(markerLayers[3].id)).toBeTruthy(); + + buffer.undo({selectionsMarkerLayer: markerLayers[3]}); + expect(buffer.getText()).toBe(textUndo); + ensureMarkerLayer(markerLayers[3], rangesBefore[1]); + buffer.redo({selectionsMarkerLayer: markerLayers[3]}); + expect(buffer.getText()).toBe(textRedo); + ensureMarkerLayer(markerLayers[3], rangesAfter[1]); + }); + + it("falls back to normal behavior when the snaphot includes multiple layerSnapshots of selections marker layers", function() { + // Transact without selectionsMarkerLayer. + // Taken snapshot includes layerSnapshot of markerLayer[0], markerLayer[1] and markerLayer[2] + buffer.transact(function() { + buffer.append("44444444\n"); + marker0.setRange(rangesAfter[0]); + marker1.setRange(rangesAfter[1]); + marker2.setRange(rangesAfter[2]); + }); + + buffer.undo({selectionsMarkerLayer: markerLayers[0]}); + expect(buffer.getText()).toBe(textUndo); + + ensureMarkerLayer(markerLayers[0], rangesBefore[0]); + ensureMarkerLayer(markerLayers[1], rangesBefore[1]); + ensureMarkerLayer(markerLayers[2], rangesBefore[2]); + expect(getFirstMarker(markerLayers[0])).toBe(marker0); + expect(getFirstMarker(markerLayers[1])).toBe(marker1); + expect(getFirstMarker(markerLayers[2])).toBe(marker2); + + buffer.redo({selectionsMarkerLayer: markerLayers[0]}); + expect(buffer.getText()).toBe(textRedo); + + ensureMarkerLayer(markerLayers[0], rangesAfter[0]); + ensureMarkerLayer(markerLayers[1], rangesAfter[1]); + ensureMarkerLayer(markerLayers[2], rangesAfter[2]); + expect(getFirstMarker(markerLayers[0])).toBe(marker0); + expect(getFirstMarker(markerLayers[1])).toBe(marker1); + expect(getFirstMarker(markerLayers[2])).toBe(marker2); + }); + + describe("selections marker layer's selective snapshotting on createCheckpoint, groupChangesSinceCheckpoint", () => it("skips snapshotting of other marker layers with the same role as the selectionsMarkerLayer", function() { + const eventHandler = jasmine.createSpy('eventHandler'); + + const args = []; + spyOn(buffer, 'createMarkerSnapshot').and.callFake(arg => args.push(arg)); + + const checkpoint1 = buffer.createCheckpoint({selectionsMarkerLayer: markerLayers[0]}); + const checkpoint2 = buffer.createCheckpoint(); + const checkpoint3 = buffer.createCheckpoint({selectionsMarkerLayer: markerLayers[2]}); + const checkpoint4 = buffer.createCheckpoint({selectionsMarkerLayer: markerLayers[1]}); + expect(args).toEqual([ + markerLayers[0], + undefined, + markerLayers[2], + markerLayers[1], + ]); + + buffer.groupChangesSinceCheckpoint(checkpoint4, {selectionsMarkerLayer: markerLayers[0]}); + buffer.groupChangesSinceCheckpoint(checkpoint3, {selectionsMarkerLayer: markerLayers[2]}); + buffer.groupChangesSinceCheckpoint(checkpoint2); + buffer.groupChangesSinceCheckpoint(checkpoint1, {selectionsMarkerLayer: markerLayers[1]}); + expect(args).toEqual([ + markerLayers[0], + undefined, + markerLayers[2], + markerLayers[1], + + markerLayers[0], + markerLayers[2], + undefined, + markerLayers[1], + ]); + })); + }); + + describe("transactions", function() { + let now = null; + + beforeEach(function() { + now = 0; + spyOn(Date, 'now').and.callFake(() => now); + + buffer = new TextBuffer({text: "hello\nworld\r\nhow are you doing?"}); + buffer.setTextInRange([[1, 3], [1, 5]], 'ms'); + }); + + describe("::transact(groupingInterval, fn)", function() { + it("groups all operations in the given function in a single transaction", function() { + buffer.transact(function() { + buffer.setTextInRange([[0, 2], [0, 5]], "y"); + buffer.transact(() => buffer.setTextInRange([[2, 13], [2, 14]], "igg")); + }); + + expect(buffer.getText()).toBe("hey\nworms\r\nhow are you digging?"); + buffer.undo(); + expect(buffer.getText()).toBe("hello\nworms\r\nhow are you doing?"); + buffer.undo(); + expect(buffer.getText()).toBe("hello\nworld\r\nhow are you doing?"); + }); + + it("halts execution of the function if the transaction is aborted", function() { + let innerContinued = false; + let outerContinued = false; + + buffer.transact(function() { + buffer.setTextInRange([[0, 2], [0, 5]], "y"); + buffer.transact(function() { + buffer.setTextInRange([[2, 13], [2, 14]], "igg"); + buffer.abortTransaction(); + innerContinued = true; + }); + outerContinued = true; + }); + + expect(innerContinued).toBe(false); + expect(outerContinued).toBe(true); + expect(buffer.getText()).toBe("hey\nworms\r\nhow are you doing?"); + }); + + it("groups all operations performed within the given function into a single undo/redo operation", function() { + buffer.transact(function() { + buffer.setTextInRange([[0, 2], [0, 5]], "y"); + buffer.setTextInRange([[2, 13], [2, 14]], "igg"); + }); + + expect(buffer.getText()).toBe("hey\nworms\r\nhow are you digging?"); + + // subsequent changes are not included in the transaction + buffer.setTextInRange([[1, 0], [1, 0]], "little "); + buffer.undo(); + expect(buffer.getText()).toBe("hey\nworms\r\nhow are you digging?"); + + // this should undo all changes in the transaction + buffer.undo(); + expect(buffer.getText()).toBe("hello\nworms\r\nhow are you doing?"); + + // previous changes are not included in the transaction + buffer.undo(); + expect(buffer.getText()).toBe("hello\nworld\r\nhow are you doing?"); + + buffer.redo(); + expect(buffer.getText()).toBe("hello\nworms\r\nhow are you doing?"); + + // this should redo all changes in the transaction + buffer.redo(); + expect(buffer.getText()).toBe("hey\nworms\r\nhow are you digging?"); + + // this should redo the change following the transaction + buffer.redo(); + expect(buffer.getText()).toBe("hey\nlittle worms\r\nhow are you digging?"); + }); + + it("does not push the transaction to the undo stack if it is empty", function() { + buffer.transact(function() {}); + buffer.undo(); + expect(buffer.getText()).toBe("hello\nworld\r\nhow are you doing?"); + + buffer.redo(); + buffer.transact(() => buffer.abortTransaction()); + buffer.undo(); + expect(buffer.getText()).toBe("hello\nworld\r\nhow are you doing?"); + }); + + it("halts execution undoes all operations since the beginning of the transaction if ::abortTransaction() is called", function() { + let continuedPastAbort = false; + buffer.transact(function() { + buffer.setTextInRange([[0, 2], [0, 5]], "y"); + buffer.setTextInRange([[2, 13], [2, 14]], "igg"); + buffer.abortTransaction(); + continuedPastAbort = true; + }); + + expect(continuedPastAbort).toBe(false); + + expect(buffer.getText()).toBe("hello\nworms\r\nhow are you doing?"); + + buffer.undo(); + expect(buffer.getText()).toBe("hello\nworld\r\nhow are you doing?"); + + buffer.redo(); + expect(buffer.getText()).toBe("hello\nworms\r\nhow are you doing?"); + + buffer.redo(); + expect(buffer.getText()).toBe("hello\nworms\r\nhow are you doing?"); + }); + + it("preserves the redo stack until a content change occurs", function() { + buffer.undo(); + expect(buffer.getText()).toBe("hello\nworld\r\nhow are you doing?"); + + // no changes occur in this transaction before aborting + buffer.transact(function() { + buffer.markRange([[0, 0], [0, 5]]); + buffer.abortTransaction(); + buffer.setTextInRange([[0, 0], [0, 5]], "hey"); + }); + + buffer.redo(); + expect(buffer.getText()).toBe("hello\nworms\r\nhow are you doing?"); + + buffer.undo(); + expect(buffer.getText()).toBe("hello\nworld\r\nhow are you doing?"); + + buffer.transact(function() { + buffer.setTextInRange([[0, 0], [0, 5]], "hey"); + buffer.abortTransaction(); + }); + expect(buffer.getText()).toBe("hello\nworld\r\nhow are you doing?"); + + buffer.redo(); + expect(buffer.getText()).toBe("hello\nworld\r\nhow are you doing?"); + }); + + it("allows nested transactions", function() { + expect(buffer.getText()).toBe("hello\nworms\r\nhow are you doing?"); + + buffer.transact(function() { + buffer.setTextInRange([[0, 2], [0, 5]], "y"); + buffer.transact(function() { + buffer.setTextInRange([[2, 13], [2, 14]], "igg"); + buffer.setTextInRange([[2, 18], [2, 19]], "'"); + }); + expect(buffer.getText()).toBe("hey\nworms\r\nhow are you diggin'?"); + buffer.undo(); + expect(buffer.getText()).toBe("hey\nworms\r\nhow are you doing?"); + buffer.redo(); + expect(buffer.getText()).toBe("hey\nworms\r\nhow are you diggin'?"); + }); + + buffer.undo(); + expect(buffer.getText()).toBe("hello\nworms\r\nhow are you doing?"); + + buffer.redo(); + expect(buffer.getText()).toBe("hey\nworms\r\nhow are you diggin'?"); + + buffer.undo(); + buffer.undo(); + expect(buffer.getText()).toBe("hello\nworld\r\nhow are you doing?"); + }); + + it("groups adjacent transactions within each other's grouping intervals", function() { + now += 1000; + buffer.transact(101, () => buffer.setTextInRange([[0, 2], [0, 5]], "y")); + + now += 100; + buffer.transact(201, () => buffer.setTextInRange([[0, 3], [0, 3]], "yy")); + + now += 200; + buffer.transact(201, () => buffer.setTextInRange([[0, 5], [0, 5]], "yy")); + + // not grouped because the previous transaction's grouping interval + // is only 200ms and we've advanced 300ms + now += 300; + buffer.transact(301, () => buffer.setTextInRange([[0, 7], [0, 7]], "!!")); + + expect(buffer.getText()).toBe("heyyyyy!!\nworms\r\nhow are you doing?"); + + buffer.undo(); + expect(buffer.getText()).toBe("heyyyyy\nworms\r\nhow are you doing?"); + + buffer.undo(); + expect(buffer.getText()).toBe("hello\nworms\r\nhow are you doing?"); + + buffer.redo(); + expect(buffer.getText()).toBe("heyyyyy\nworms\r\nhow are you doing?"); + + buffer.redo(); + expect(buffer.getText()).toBe("heyyyyy!!\nworms\r\nhow are you doing?"); + }); + + it("allows undo/redo within transactions, but not beyond the start of the containing transaction", function() { + buffer.setText(""); + buffer.markPosition([0, 0]); + + buffer.append("a"); + + buffer.transact(function() { + buffer.append("b"); + buffer.transact(() => buffer.append("c")); + buffer.append("d"); + + expect(buffer.undo()).toBe(true); + expect(buffer.getText()).toBe("abc"); + + expect(buffer.undo()).toBe(true); + expect(buffer.getText()).toBe("ab"); + + expect(buffer.undo()).toBe(true); + expect(buffer.getText()).toBe("a"); + + expect(buffer.undo()).toBe(false); + expect(buffer.getText()).toBe("a"); + + expect(buffer.redo()).toBe(true); + expect(buffer.getText()).toBe("ab"); + + expect(buffer.redo()).toBe(true); + expect(buffer.getText()).toBe("abc"); + + expect(buffer.redo()).toBe(true); + expect(buffer.getText()).toBe("abcd"); + + expect(buffer.redo()).toBe(false); + expect(buffer.getText()).toBe("abcd"); + }); + + expect(buffer.undo()).toBe(true); + expect(buffer.getText()).toBe("a"); + }); + + it("does not error if the buffer is destroyed in a change callback within the transaction", function() { + buffer.onDidChange(() => buffer.destroy()); + const result = buffer.transact(function() { + buffer.append('!'); + return 'hi'; + }); + expect(result).toBe('hi'); + }); + }); + }); + + describe("checkpoints", function() { + beforeEach(() => buffer = new TextBuffer); + + describe("::getChangesSinceCheckpoint(checkpoint)", function() { + it("returns a list of changes that have been made since the checkpoint", function() { + buffer.setText('abc\ndef\nghi\njkl\n'); + buffer.append("mno\n"); + const checkpoint = buffer.createCheckpoint(); + buffer.transact(function() { + buffer.append('pqr\n'); + buffer.append('stu\n'); + }); + buffer.append('vwx\n'); + buffer.setTextInRange([[1, 0], [1, 2]], 'yz'); + + expect(buffer.getText()).toBe('abc\nyzf\nghi\njkl\nmno\npqr\nstu\nvwx\n'); + assertChangesEqual(buffer.getChangesSinceCheckpoint(checkpoint), [ + { + oldRange: [[1, 0], [1, 2]], + newRange: [[1, 0], [1, 2]], + oldText: "de", + newText: "yz", + }, + { + oldRange: [[5, 0], [5, 0]], + newRange: [[5, 0], [8, 0]], + oldText: "", + newText: "pqr\nstu\nvwx\n", + } + ]); + }); + + it("returns an empty list of changes when no change has been made since the checkpoint", function() { + const checkpoint = buffer.createCheckpoint(); + expect(buffer.getChangesSinceCheckpoint(checkpoint)).toEqual([]); + }); + + it("returns an empty list of changes when the checkpoint doesn't exist", function() { + buffer.transact(function() { + buffer.append('abc\n'); + buffer.append('def\n'); + }); + buffer.append('ghi\n'); + expect(buffer.getChangesSinceCheckpoint(-1)).toEqual([]); + }); + }); + + describe("::revertToCheckpoint(checkpoint)", () => it("undoes all changes following the checkpoint", function() { + buffer.append("hello"); + const checkpoint = buffer.createCheckpoint(); + + buffer.transact(function() { + buffer.append("\n"); + buffer.append("world"); + }); + + buffer.append("\n"); + buffer.append("how are you?"); + + const result = buffer.revertToCheckpoint(checkpoint); + expect(result).toBe(true); + expect(buffer.getText()).toBe("hello"); + + buffer.redo(); + expect(buffer.getText()).toBe("hello"); + })); + + describe("::groupChangesSinceCheckpoint(checkpoint)", function() { + it("combines all changes since the checkpoint into a single transaction", function() { + const historyLayer = buffer.addMarkerLayer({maintainHistory: true}); + + buffer.append("one\n"); + const marker = historyLayer.markRange([[0, 1], [0, 2]]); + marker.setProperties({a: 'b'}); + + const checkpoint = buffer.createCheckpoint(); + buffer.append("two\n"); + buffer.transact(function() { + buffer.append("three\n"); + buffer.append("four"); + }); + + marker.setRange([[0, 1], [2, 3]]); + marker.setProperties({a: 'c'}); + const result = buffer.groupChangesSinceCheckpoint(checkpoint); + + expect(result).toBeTruthy(); + expect(buffer.getText()).toBe(`\ +one +two +three +four\ +` + ); + expect(marker.getRange()).toEqual([[0, 1], [2, 3]]); + expect(marker.getProperties()).toEqual({a: 'c'}); + + buffer.undo(); + expect(buffer.getText()).toBe("one\n"); + expect(marker.getRange()).toEqual([[0, 1], [0, 2]]); + expect(marker.getProperties()).toEqual({a: 'b'}); + + buffer.redo(); + expect(buffer.getText()).toBe(`\ +one +two +three +four\ +` + ); + expect(marker.getRange()).toEqual([[0, 1], [2, 3]]); + expect(marker.getProperties()).toEqual({a: 'c'}); + }); + + it("skips any later checkpoints when grouping changes", function() { + buffer.append("one\n"); + const checkpoint = buffer.createCheckpoint(); + buffer.append("two\n"); + const checkpoint2 = buffer.createCheckpoint(); + buffer.append("three"); + + buffer.groupChangesSinceCheckpoint(checkpoint); + expect(buffer.revertToCheckpoint(checkpoint2)).toBe(false); + + expect(buffer.getText()).toBe(`\ +one +two +three\ +` + ); + + buffer.undo(); + expect(buffer.getText()).toBe("one\n"); + + buffer.redo(); + expect(buffer.getText()).toBe(`\ +one +two +three\ +` + ); + }); + + it("does nothing when no changes have been made since the checkpoint", function() { + buffer.append("one\n"); + const checkpoint = buffer.createCheckpoint(); + const result = buffer.groupChangesSinceCheckpoint(checkpoint); + expect(result).toBeTruthy(); + buffer.undo(); + expect(buffer.getText()).toBe(""); + }); + + it("returns false and does nothing when the checkpoint is not in the buffer's history", function() { + buffer.append("hello\n"); + const checkpoint = buffer.createCheckpoint(); + buffer.undo(); + buffer.append("world"); + const result = buffer.groupChangesSinceCheckpoint(checkpoint); + expect(result).toBeFalsy(); + buffer.undo(); + expect(buffer.getText()).toBe(""); + }); + }); + + it("skips checkpoints when undoing", function() { + buffer.append("hello"); + buffer.createCheckpoint(); + buffer.createCheckpoint(); + buffer.createCheckpoint(); + buffer.undo(); + expect(buffer.getText()).toBe(""); + }); + + it("preserves checkpoints across undo and redo", function() { + buffer.append("a"); + buffer.append("b"); + const checkpoint1 = buffer.createCheckpoint(); + buffer.append("c"); + const checkpoint2 = buffer.createCheckpoint(); + + buffer.undo(); + expect(buffer.getText()).toBe("ab"); + + buffer.redo(); + expect(buffer.getText()).toBe("abc"); + + buffer.append("d"); + + expect(buffer.revertToCheckpoint(checkpoint2)).toBe(true); + expect(buffer.getText()).toBe("abc"); + expect(buffer.revertToCheckpoint(checkpoint1)).toBe(true); + expect(buffer.getText()).toBe("ab"); + }); + + it("handles checkpoints created when there have been no changes", function() { + const checkpoint = buffer.createCheckpoint(); + buffer.undo(); + buffer.append("hello"); + buffer.revertToCheckpoint(checkpoint); + expect(buffer.getText()).toBe(""); + }); + + it("returns false when the checkpoint is not in the buffer's history", function() { + buffer.append("hello\n"); + const checkpoint = buffer.createCheckpoint(); + buffer.undo(); + buffer.append("world"); + expect(buffer.revertToCheckpoint(checkpoint)).toBe(false); + expect(buffer.getText()).toBe("world"); + }); + + it("does not allow changes based on checkpoints outside of the current transaction", function() { + const checkpoint = buffer.createCheckpoint(); + + buffer.append("a"); + + buffer.transact(function() { + expect(buffer.revertToCheckpoint(checkpoint)).toBe(false); + expect(buffer.getText()).toBe("a"); + + buffer.append("b"); + + expect(buffer.groupChangesSinceCheckpoint(checkpoint)).toBeFalsy(); + }); + + buffer.undo(); + expect(buffer.getText()).toBe("a"); + }); + }); + + describe("::groupLastChanges()", () => it("groups the last two changes into a single transaction", function() { + buffer = new TextBuffer(); + const layer = buffer.addMarkerLayer({maintainHistory: true}); + + buffer.append('a'); + + // Group two transactions, ensure before/after markers snapshots are preserved + const marker = layer.markPosition([0, 0]); + buffer.transact(() => buffer.append('b')); + buffer.createCheckpoint(); + buffer.transact(function() { + buffer.append('ccc'); + marker.setHeadPosition([0, 2]); + }); + + expect(buffer.groupLastChanges()).toBe(true); + buffer.undo(); + expect(marker.getHeadPosition()).toEqual([0, 0]); + expect(buffer.getText()).toBe('a'); + buffer.redo(); + expect(marker.getHeadPosition()).toEqual([0, 2]); + buffer.undo(); + + // Group two bare changes + buffer.transact(function() { + buffer.append('b'); + buffer.createCheckpoint(); + buffer.append('c'); + expect(buffer.groupLastChanges()).toBe(true); + buffer.undo(); + expect(buffer.getText()).toBe('a'); + }); + + // Group a transaction with a bare change + buffer.transact(function() { + buffer.transact(function() { + buffer.append('b'); + buffer.append('c'); + }); + buffer.append('d'); + expect(buffer.groupLastChanges()).toBe(true); + buffer.undo(); + expect(buffer.getText()).toBe('a'); + }); + + // Group a bare change with a transaction + buffer.transact(function() { + buffer.append('b'); + buffer.transact(function() { + buffer.append('c'); + buffer.append('d'); + }); + expect(buffer.groupLastChanges()).toBe(true); + buffer.undo(); + expect(buffer.getText()).toBe('a'); + }); + + // Can't group past the beginning of an open transaction + buffer.transact(function() { + expect(buffer.groupLastChanges()).toBe(false); + buffer.append('b'); + expect(buffer.groupLastChanges()).toBe(false); + buffer.append('c'); + expect(buffer.groupLastChanges()).toBe(true); + buffer.undo(); + expect(buffer.getText()).toBe('a'); + }); + })); + + describe("::setHistoryProvider(provider)", () => it("replaces the currently active history provider with the passed one", function() { + buffer = new TextBuffer({text: ''}); + buffer.insert([0, 0], 'Lorem '); + buffer.insert([0, 6], 'ipsum '); + expect(buffer.getText()).toBe('Lorem ipsum '); + + buffer.undo(); + expect(buffer.getText()).toBe('Lorem '); + + buffer.setHistoryProvider(new DefaultHistoryProvider(buffer)); + buffer.undo(); + expect(buffer.getText()).toBe('Lorem '); + + buffer.insert([0, 6], 'dolor '); + expect(buffer.getText()).toBe('Lorem dolor '); + + buffer.undo(); + expect(buffer.getText()).toBe('Lorem '); + })); + + describe("::getHistory(maxEntries) and restoreDefaultHistoryProvider(history)", function() { + it("returns a base text and the state of the last `maxEntries` entries in the undo and redo stacks", function() { + buffer = new TextBuffer({text: ''}); + const markerLayer = buffer.addMarkerLayer({maintainHistory: true}); + + buffer.append('Lorem '); + buffer.append('ipsum '); + buffer.append('dolor '); + markerLayer.markPosition([0, 2]); + const markersSnapshotAtCheckpoint1 = buffer.createMarkerSnapshot(); + const checkpoint1 = buffer.createCheckpoint(); + buffer.append('sit '); + buffer.append('amet '); + buffer.append('consecteur '); + markerLayer.markPosition([0, 4]); + const markersSnapshotAtCheckpoint2 = buffer.createMarkerSnapshot(); + const checkpoint2 = buffer.createCheckpoint(); + buffer.append('adipiscit '); + buffer.append('elit '); + buffer.undo(); + buffer.undo(); + buffer.undo(); + + const history = buffer.getHistory(3); + expect(history.baseText).toBe('Lorem ipsum dolor '); + expect(history.nextCheckpointId).toBe(buffer.createCheckpoint()); + expect(history.undoStack).toEqual([ + { + type: 'checkpoint', + id: checkpoint1, + markers: markersSnapshotAtCheckpoint1 + }, + { + type: 'transaction', + changes: [{oldStart: Point(0, 18), oldEnd: Point(0, 18), newStart: Point(0, 18), newEnd: Point(0, 22), oldText: '', newText: 'sit '}], + markersBefore: markersSnapshotAtCheckpoint1, + markersAfter: markersSnapshotAtCheckpoint1 + }, + { + type: 'transaction', + changes: [{oldStart: Point(0, 22), oldEnd: Point(0, 22), newStart: Point(0, 22), newEnd: Point(0, 27), oldText: '', newText: 'amet '}], + markersBefore: markersSnapshotAtCheckpoint1, + markersAfter: markersSnapshotAtCheckpoint1 + } + ]); + expect(history.redoStack).toEqual([ + { + type: 'transaction', + changes: [{oldStart: Point(0, 38), oldEnd: Point(0, 38), newStart: Point(0, 38), newEnd: Point(0, 48), oldText: '', newText: 'adipiscit '}], + markersBefore: markersSnapshotAtCheckpoint2, + markersAfter: markersSnapshotAtCheckpoint2 + }, + { + type: 'checkpoint', + id: checkpoint2, + markers: markersSnapshotAtCheckpoint2 + }, + { + type: 'transaction', + changes: [{oldStart: Point(0, 27), oldEnd: Point(0, 27), newStart: Point(0, 27), newEnd: Point(0, 38), oldText: '', newText: 'consecteur '}], + markersBefore: markersSnapshotAtCheckpoint1, + markersAfter: markersSnapshotAtCheckpoint1 + } + ]); + + buffer.createCheckpoint(); + buffer.append('x'); + buffer.undo(); + buffer.clearUndoStack(); + + expect(buffer.getHistory()).not.toEqual(history); + buffer.restoreDefaultHistoryProvider(history); + expect(buffer.getHistory()).toEqual(history); + }); + + it("throws an error when called within a transaction", function() { + buffer = new TextBuffer(); + expect(() => buffer.transact(() => buffer.getHistory(3))).toThrowError(); + }); + }); + + describe("::getTextInRange(range)", function() { + it("returns the text in a given range", function() { + buffer = new TextBuffer({text: "hello\nworld\r\nhow are you doing?"}); + expect(buffer.getTextInRange([[1, 1], [1, 4]])).toBe("orl"); + expect(buffer.getTextInRange([[0, 3], [2, 3]])).toBe("lo\nworld\r\nhow"); + expect(buffer.getTextInRange([[0, 0], [2, 18]])).toBe(buffer.getText()); + }); + + it("clips the given range", function() { + buffer = new TextBuffer({text: "hello\nworld\r\nhow are you doing?"}); + expect(buffer.getTextInRange([[-100, -100], [100, 100]])).toBe(buffer.getText()); + }); + }); + + describe("::clipPosition(position)", function() { + it("returns a valid position closest to the given position", function() { + buffer = new TextBuffer({text: "hello\nworld\r\nhow are you doing?"}); + expect(buffer.clipPosition([-1, -1])).toEqual([0, 0]); + expect(buffer.clipPosition([-1, 2])).toEqual([0, 0]); + expect(buffer.clipPosition([0, -1])).toEqual([0, 0]); + expect(buffer.clipPosition([0, 20])).toEqual([0, 5]); + expect(buffer.clipPosition([1, -1])).toEqual([1, 0]); + expect(buffer.clipPosition([1, 20])).toEqual([1, 5]); + expect(buffer.clipPosition([10, 0])).toEqual([2, 18]); + expect(buffer.clipPosition([Infinity, 0])).toEqual([2, 18]); + }); + + it("throws an error when given an invalid point", function() { + buffer = new TextBuffer({text: "hello\nworld\r\nhow are you doing?"}); + expect(() => buffer.clipPosition([NaN, 1])) + .toThrowError("Invalid Point: (NaN, 1)"); + expect(() => buffer.clipPosition([0, NaN])) + .toThrowError("Invalid Point: (0, NaN)"); + expect(() => buffer.clipPosition([0, {}])) + .toThrowError("Invalid Point: (0, [object Object])"); + }); + }); + + describe("::characterIndexForPosition(position)", function() { + beforeEach(() => buffer = new TextBuffer({text: "zero\none\r\ntwo\nthree"})); + + it("returns the absolute character offset for the given position", function() { + expect(buffer.characterIndexForPosition([0, 0])).toBe(0); + expect(buffer.characterIndexForPosition([0, 1])).toBe(1); + expect(buffer.characterIndexForPosition([0, 4])).toBe(4); + expect(buffer.characterIndexForPosition([1, 0])).toBe(5); + expect(buffer.characterIndexForPosition([1, 1])).toBe(6); + expect(buffer.characterIndexForPosition([1, 3])).toBe(8); + expect(buffer.characterIndexForPosition([2, 0])).toBe(10); + expect(buffer.characterIndexForPosition([2, 1])).toBe(11); + expect(buffer.characterIndexForPosition([3, 0])).toBe(14); + expect(buffer.characterIndexForPosition([3, 5])).toBe(19); + }); + + it("clips the given position before translating", function() { + expect(buffer.characterIndexForPosition([-1, -1])).toBe(0); + expect(buffer.characterIndexForPosition([1, 100])).toBe(8); + expect(buffer.characterIndexForPosition([100, 100])).toBe(19); + }); + }); + + describe("::positionForCharacterIndex(offset)", function() { + beforeEach(() => buffer = new TextBuffer({text: "zero\none\r\ntwo\nthree"})); + + it("returns the position for the given absolute character offset", function() { + expect(buffer.positionForCharacterIndex(0)).toEqual([0, 0]); + expect(buffer.positionForCharacterIndex(1)).toEqual([0, 1]); + expect(buffer.positionForCharacterIndex(4)).toEqual([0, 4]); + expect(buffer.positionForCharacterIndex(5)).toEqual([1, 0]); + expect(buffer.positionForCharacterIndex(6)).toEqual([1, 1]); + expect(buffer.positionForCharacterIndex(8)).toEqual([1, 3]); + expect(buffer.positionForCharacterIndex(10)).toEqual([2, 0]); + expect(buffer.positionForCharacterIndex(11)).toEqual([2, 1]); + expect(buffer.positionForCharacterIndex(14)).toEqual([3, 0]); + expect(buffer.positionForCharacterIndex(19)).toEqual([3, 5]); + }); + + it("clips the given offset before translating", function() { + expect(buffer.positionForCharacterIndex(-1)).toEqual([0, 0]); + expect(buffer.positionForCharacterIndex(20)).toEqual([3, 5]); + }); +}); + + describe("serialization", function() { + const expectSameMarkers = function(left, right) { + const markers1 = left.getMarkers().sort((a, b) => a.compare(b)); + const markers2 = right.getMarkers().sort((a, b) => a.compare(b)); + expect(markers1.length).toBe(markers2.length); + for (let i = 0; i < markers1.length; i++) { + var marker1 = markers1[i]; + expect(marker1).toEqual(markers2[i]); + } + }; + + it("can serialize / deserialize the buffer along with its history, marker layers, and display layers", function(done) { + const bufferA = new TextBuffer({text: "hello\nworld\r\nhow are you doing?"}); + const displayLayer1A = bufferA.addDisplayLayer(); + const displayLayer2A = bufferA.addDisplayLayer(); + displayLayer1A.foldBufferRange([[0, 1], [0, 3]]); + displayLayer2A.foldBufferRange([[0, 0], [0, 2]]); + bufferA.createCheckpoint(); + bufferA.setTextInRange([[0, 5], [0, 5]], " there"); + bufferA.transact(() => bufferA.setTextInRange([[1, 0], [1, 5]], "friend")); + const layerA = bufferA.addMarkerLayer({maintainHistory: true, persistent: true}); + layerA.markRange([[0, 6], [0, 8]], {reversed: true, foo: 1}); + const layerB = bufferA.addMarkerLayer({maintainHistory: true, persistent: true, role: "selections"}); + const marker2A = bufferA.markPosition([2, 2], {bar: 2}); + bufferA.transact(function() { + bufferA.setTextInRange([[1, 0], [1, 0]], "good "); + bufferA.append("?"); + marker2A.setProperties({bar: 3, baz: 4}); + }); + layerA.markRange([[0, 4], [0, 5]], {invalidate: 'inside'}); + bufferA.setTextInRange([[0, 5], [0, 5]], "oo"); + bufferA.undo(); + + const state = JSON.parse(JSON.stringify(bufferA.serialize())); + TextBuffer.deserialize(state).then(function(bufferB) { + expect(bufferB.getText()).toBe("hello there\ngood friend\r\nhow are you doing??"); + expectSameMarkers(bufferB.getMarkerLayer(layerA.id), layerA); + expect(bufferB.getDisplayLayer(displayLayer1A.id).foldsIntersectingBufferRange([[0, 1], [0, 3]]).length).toBe(1); + expect(bufferB.getDisplayLayer(displayLayer2A.id).foldsIntersectingBufferRange([[0, 0], [0, 2]]).length).toBe(1); + const displayLayer3B = bufferB.addDisplayLayer(); + expect(displayLayer3B.id).toBeGreaterThan(displayLayer1A.id); + expect(displayLayer3B.id).toBeGreaterThan(displayLayer2A.id); + + expect(bufferB.getMarkerLayer(layerB.id).getRole()).toBe("selections"); + expect(bufferB.selectionsMarkerLayerIds.has(layerB.id)).toBe(true); + expect(bufferB.selectionsMarkerLayerIds.size).toBe(1); + + bufferA.redo(); + bufferB.redo(); + expect(bufferB.getText()).toBe("hellooo there\ngood friend\r\nhow are you doing??"); + expectSameMarkers(bufferB.getMarkerLayer(layerA.id), layerA); + expect(bufferB.getMarkerLayer(layerA.id).maintainHistory).toBe(true); + expect(bufferB.getMarkerLayer(layerA.id).persistent).toBe(true); + + bufferA.undo(); + bufferB.undo(); + expect(bufferB.getText()).toBe("hello there\ngood friend\r\nhow are you doing??"); + expectSameMarkers(bufferB.getMarkerLayer(layerA.id), layerA); + + bufferA.undo(); + bufferB.undo(); + expect(bufferB.getText()).toBe("hello there\nfriend\r\nhow are you doing?"); + expectSameMarkers(bufferB.getMarkerLayer(layerA.id), layerA); + + bufferA.undo(); + bufferB.undo(); + expect(bufferB.getText()).toBe("hello there\nworld\r\nhow are you doing?"); + expectSameMarkers(bufferB.getMarkerLayer(layerA.id), layerA); + + bufferA.undo(); + bufferB.undo(); + expect(bufferB.getText()).toBe("hello\nworld\r\nhow are you doing?"); + expectSameMarkers(bufferB.getMarkerLayer(layerA.id), layerA); + + // Accounts for deserialized markers when selecting the next marker's id + const marker3A = layerA.markRange([[0, 1], [2, 3]]); + const marker3B = bufferB.getMarkerLayer(layerA.id).markRange([[0, 1], [2, 3]]); + expect(marker3B.id).toBe(marker3A.id); + + // Doesn't try to reload the buffer since it has no file. + setTimeout(function() { + expect(bufferB.getText()).toBe("hello\nworld\r\nhow are you doing?"); + done(); + }, 50); + }); + }); + + it("serializes / deserializes the buffer's persistent custom marker layers", function(done) { + const bufferA = new TextBuffer("abcdefghijklmnopqrstuvwxyz"); + + const layer1A = bufferA.addMarkerLayer(); + const layer2A = bufferA.addMarkerLayer({persistent: true}); + + layer1A.markRange([[0, 1], [0, 2]]); + layer1A.markRange([[0, 3], [0, 4]]); + + layer2A.markRange([[0, 5], [0, 6]]); + layer2A.markRange([[0, 7], [0, 8]]); + + TextBuffer.deserialize(JSON.parse(JSON.stringify(bufferA.serialize()))).then(function(bufferB) { + const layer1B = bufferB.getMarkerLayer(layer1A.id); + const layer2B = bufferB.getMarkerLayer(layer2A.id); + expect(layer2B.persistent).toBe(true); + + expect(layer1B).toBe(undefined); + expectSameMarkers(layer2A, layer2B); + done(); + }); + }); + + it("doesn't serialize the default marker layer", function(done) { + const bufferA = new TextBuffer({text: "hello\nworld\r\nhow are you doing?"}); + const markerLayerA = bufferA.getDefaultMarkerLayer(); + const marker1A = bufferA.markRange([[0, 1], [1, 2]], {foo: 1}); + + TextBuffer.deserialize(bufferA.serialize()).then(function(bufferB) { + const markerLayerB = bufferB.getDefaultMarkerLayer(); + expect(bufferB.getMarker(marker1A.id)).toBeUndefined(); + done(); + }); + }); + + it("doesn't attempt to serialize snapshots for destroyed marker layers", function() { + buffer = new TextBuffer({text: "abc"}); + const markerLayer = buffer.addMarkerLayer({maintainHistory: true, persistent: true}); + markerLayer.markPosition([0, 3]); + buffer.insert([0, 0], 'x'); + markerLayer.destroy(); + + expect(() => buffer.serialize()).not.toThrowError(); + }); + + it("doesn't remember marker layers when calling serialize with {markerLayers: false}", function(done) { + const bufferA = new TextBuffer({text: "world"}); + const layerA = bufferA.addMarkerLayer({maintainHistory: true}); + const markerA = layerA.markPosition([0, 3]); + let markerB = null; + bufferA.transact(function() { + bufferA.insert([0, 0], 'hello '); + markerB = layerA.markPosition([0, 5]); + }); + bufferA.undo(); + + TextBuffer.deserialize(bufferA.serialize({markerLayers: false})).then(function(bufferB) { + expect(bufferB.getText()).toBe("world"); + expect(bufferB.getMarkerLayer(layerA.id)?.getMarker(markerA.id)).toBeUndefined(); + expect(bufferB.getMarkerLayer(layerA.id)?.getMarker(markerB.id)).toBeUndefined(); + + bufferB.redo(); + expect(bufferB.getText()).toBe("hello world"); + expect(bufferB.getMarkerLayer(layerA.id)?.getMarker(markerA.id)).toBeUndefined(); + expect(bufferB.getMarkerLayer(layerA.id)?.getMarker(markerB.id)).toBeUndefined(); + + bufferB.undo(); + expect(bufferB.getText()).toBe("world"); + expect(bufferB.getMarkerLayer(layerA.id)?.getMarker(markerA.id)).toBeUndefined(); + expect(bufferB.getMarkerLayer(layerA.id)?.getMarker(markerB.id)).toBeUndefined(); + done(); + }); + }); + + it("doesn't remember history when calling serialize with {history: false}", function(done) { + const bufferA = new TextBuffer({text: 'abc'}); + bufferA.append('def'); + bufferA.append('ghi'); + + TextBuffer.deserialize(bufferA.serialize({history: false})).then(function(bufferB) { + expect(bufferB.getText()).toBe("abcdefghi"); + expect(bufferB.undo()).toBe(false); + expect(bufferB.getText()).toBe("abcdefghi"); + done(); + }); + }); + + it("serializes / deserializes the buffer's unique identifier", function(done) { + const bufferA = new TextBuffer(); + TextBuffer.deserialize(JSON.parse(JSON.stringify(bufferA.serialize()))).then(function(bufferB) { + expect(bufferB.getId()).toEqual(bufferA.getId()); + done(); + }); + }); + + it("doesn't deserialize a state that was serialized with a different buffer version", function(done) { + const bufferA = new TextBuffer(); + const serializedBuffer = JSON.parse(JSON.stringify(bufferA.serialize())); + serializedBuffer.version = 123456789; + + TextBuffer.deserialize(serializedBuffer).then(function(bufferB) { + expect(bufferB).toBeUndefined(); + done(); + }); + }); + + it("doesn't deserialize a state referencing a file that no longer exists", function(done) { + const tempDir = fs.realpathSync(temp.mkdirSync('text-buffer')); + const filePath = join(tempDir, 'file.txt'); + fs.writeFileSync(filePath, "something\n"); + + const bufferA = TextBuffer.loadSync(filePath); + const state = bufferA.serialize(); + + fs.unlinkSync(filePath); + + state.mustExist = true; + TextBuffer.deserialize(state).then( + () => expect('serialization succeeded with mustExist: true').toBeUndefined(), + err => expect(err.code).toBe('ENOENT')).then(done, done); + }); + + describe("when the serialized buffer was unsaved and had no path", () => it("restores the previous unsaved state of the buffer", function(done) { + buffer = new TextBuffer(); + buffer.setText("abc"); + + TextBuffer.deserialize(buffer.serialize()).then(function(buffer2) { + expect(buffer2.getPath()).toBeUndefined(); + expect(buffer2.getText()).toBe("abc"); + done(); + }); + })); + }); + + describe("::getRange()", () => it("returns the range of the entire buffer text", function() { + buffer = new TextBuffer("abc\ndef\nghi"); + expect(buffer.getRange()).toEqual([[0, 0], [2, 3]]); +})); + + describe("::getLength()", () => it("returns the lenght of the entire buffer text", function() { + buffer = new TextBuffer("abc\ndef\nghi"); + expect(buffer.getLength()).toBe("abc\ndef\nghi".length); + })); + + describe("::rangeForRow(row, includeNewline)", function() { + beforeEach(() => buffer = new TextBuffer("this\nis a test\r\ntesting")); + + describe("if includeNewline is false (the default)", () => it("returns a range from the beginning of the line to the end of the line", function() { + expect(buffer.rangeForRow(0)).toEqual([[0, 0], [0, 4]]); + expect(buffer.rangeForRow(1)).toEqual([[1, 0], [1, 9]]); + expect(buffer.rangeForRow(2)).toEqual([[2, 0], [2, 7]]); + })); + + describe("if includeNewline is true", () => it("returns a range from the beginning of the line to the beginning of the next (if it exists)", function() { + expect(buffer.rangeForRow(0, true)).toEqual([[0, 0], [1, 0]]); + expect(buffer.rangeForRow(1, true)).toEqual([[1, 0], [2, 0]]); + expect(buffer.rangeForRow(2, true)).toEqual([[2, 0], [2, 7]]); + })); + + describe("if the given row is out of range", () => it("returns the range of the nearest valid row", function() { + expect(buffer.rangeForRow(-1)).toEqual([[0, 0], [0, 4]]); + expect(buffer.rangeForRow(10)).toEqual([[2, 0], [2, 7]]); + })); + }); + + describe("::onDidChangePath()", function() { + let filePath, newPath, bufferToChange, eventHandler; + + beforeEach(function() { + const tempDir = fs.realpathSync(temp.mkdirSync('text-buffer')); + filePath = join(tempDir, "manipulate-me"); + newPath = `${filePath}-i-moved`; + fs.writeFileSync(filePath, ""); + bufferToChange = TextBuffer.loadSync(filePath); + }); + + afterEach(function() { + bufferToChange.destroy(); + fs.removeSync(filePath); + fs.removeSync(newPath); + }); + + it("notifies observers when the buffer is saved to a new path", function(done) { + bufferToChange.onDidChangePath(function(p) { + expect(p).toBe(newPath); + done(); + }); + bufferToChange.saveAs(newPath); + }); + + it("notifies observers when the buffer's file is moved", function(done) { + // FIXME: This doesn't pass on Linux + if (['linux', 'win32'].includes(process.platform)) { + done(); + return; + } + + bufferToChange.onDidChangePath(function(p) { + expect(p).toBe(newPath); + done(); + }); + + fs.removeSync(newPath); + fs.moveSync(filePath, newPath); + }); + }); + + // This spec is no longer needed because `onWillThrowWatchError` is a no-op. + // `pathwatcher` can't fulfill the callback because it chooses not to reload + // the entire file every time it changes (for performance reasons), hence it + // stopped throwing this error a long time ago. + // + // (Indeed, this test is tautological, since it manually generates the event.) + xdescribe("::onWillThrowWatchError", () => it("notifies observers when the file has a watch error", function() { + const filePath = temp.openSync('atom').path; + fs.writeFileSync(filePath, ''); + + buffer = TextBuffer.loadSync(filePath); + + const eventHandler = jasmine.createSpy('eventHandler'); + buffer.onWillThrowWatchError(eventHandler); + + buffer.file.emitter.emit('will-throw-watch-error', 'arg'); + expect(eventHandler).toHaveBeenCalledWith('arg'); + })); + + describe("::getLines()", () => it("returns an array of lines in the text contents", function() { + const filePath = require.resolve('./fixtures/sample.js'); + const fileContents = fs.readFileSync(filePath, 'utf8'); + buffer = TextBuffer.loadSync(filePath); + expect(buffer.getLines().length).toBe(fileContents.split("\n").length); + expect(buffer.getLines().join('\n')).toBe(fileContents); + })); + + describe("::setTextInRange(range, string)", function() { + let changeHandler = null; + + beforeEach(function(done) { + const filePath = require.resolve('./fixtures/sample.js'); + const fileContents = fs.readFileSync(filePath, 'utf8'); + TextBuffer.load(filePath).then(function(result) { + buffer = result; + changeHandler = jasmine.createSpy('changeHandler'); + buffer.onDidChange(changeHandler); + done(); + }); + }); + + describe("when used to insert (called with an empty range and a non-empty string)", function() { + describe("when the given string has no newlines", () => it("inserts the string at the location of the given range", function() { + const range = [[3, 4], [3, 4]]; + buffer.setTextInRange(range, "foo"); + + expect(buffer.lineForRow(2)).toBe(" if (items.length <= 1) return items;"); + expect(buffer.lineForRow(3)).toBe(" foovar pivot = items.shift(), current, left = [], right = [];"); + expect(buffer.lineForRow(4)).toBe(" while(items.length > 0) {"); + + expect(changeHandler).toHaveBeenCalled(); + const [event] = changeHandler.calls.allArgs()[0]; + expect(event.oldRange).toEqual(range); + expect(event.newRange).toEqual([[3, 4], [3, 7]]); + expect(event.oldText).toBe(""); + expect(event.newText).toBe("foo"); + })); + + describe("when the given string has newlines", () => it("inserts the lines at the location of the given range", function() { + const range = [[3, 4], [3, 4]]; + + buffer.setTextInRange(range, "foo\n\nbar\nbaz"); + + expect(buffer.lineForRow(2)).toBe(" if (items.length <= 1) return items;"); + expect(buffer.lineForRow(3)).toBe(" foo"); + expect(buffer.lineForRow(4)).toBe(""); + expect(buffer.lineForRow(5)).toBe("bar"); + expect(buffer.lineForRow(6)).toBe("bazvar pivot = items.shift(), current, left = [], right = [];"); + expect(buffer.lineForRow(7)).toBe(" while(items.length > 0) {"); + + expect(changeHandler).toHaveBeenCalled(); + const [event] = changeHandler.calls.allArgs()[0]; + expect(event.oldRange).toEqual(range); + expect(event.newRange).toEqual([[3, 4], [6, 3]]); + expect(event.oldText).toBe(""); + expect(event.newText).toBe("foo\n\nbar\nbaz"); + })); + }); + + describe("when used to remove (called with a non-empty range and an empty string)", function() { + describe("when the range is contained within a single line", () => it("removes the characters within the range", function() { + const range = [[3, 4], [3, 7]]; + buffer.setTextInRange(range, ""); + + expect(buffer.lineForRow(2)).toBe(" if (items.length <= 1) return items;"); + expect(buffer.lineForRow(3)).toBe(" pivot = items.shift(), current, left = [], right = [];"); + expect(buffer.lineForRow(4)).toBe(" while(items.length > 0) {"); + + expect(changeHandler).toHaveBeenCalled(); + const [event] = changeHandler.calls.allArgs()[0]; + expect(event.oldRange).toEqual(range); + expect(event.newRange).toEqual([[3, 4], [3, 4]]); + expect(event.oldText).toBe("var"); + expect(event.newText).toBe(""); + })); + + describe("when the range spans 2 lines", () => it("removes the characters within the range and joins the lines", function() { + const range = [[3, 16], [4, 4]]; + buffer.setTextInRange(range, ""); + + expect(buffer.lineForRow(2)).toBe(" if (items.length <= 1) return items;"); + expect(buffer.lineForRow(3)).toBe(" var pivot = while(items.length > 0) {"); + expect(buffer.lineForRow(4)).toBe(" current = items.shift();"); + + expect(changeHandler).toHaveBeenCalled(); + const [event] = changeHandler.calls.allArgs()[0]; + expect(event.oldRange).toEqual(range); + expect(event.newRange).toEqual([[3, 16], [3, 16]]); + expect(event.oldText).toBe("items.shift(), current, left = [], right = [];\n "); + expect(event.newText).toBe(""); + })); + + describe("when the range spans more than 2 lines", () => it("removes the characters within the range, joining the first and last line and removing the lines in-between", function() { + buffer.setTextInRange([[3, 16], [11, 9]], ""); + + expect(buffer.lineForRow(2)).toBe(" if (items.length <= 1) return items;"); + expect(buffer.lineForRow(3)).toBe(" var pivot = sort(Array.apply(this, arguments));"); + expect(buffer.lineForRow(4)).toBe("};"); + })); + }); + + describe("when used to replace text with other text (called with non-empty range and non-empty string)", () => it("replaces the old text with the new text", function() { + const range = [[3, 16], [11, 9]]; + const oldText = buffer.getTextInRange(range); + + buffer.setTextInRange(range, "foo\nbar"); + + expect(buffer.lineForRow(2)).toBe(" if (items.length <= 1) return items;"); + expect(buffer.lineForRow(3)).toBe(" var pivot = foo"); + expect(buffer.lineForRow(4)).toBe("barsort(Array.apply(this, arguments));"); + expect(buffer.lineForRow(5)).toBe("};"); + + expect(changeHandler).toHaveBeenCalled(); + const [event] = changeHandler.calls.allArgs()[0]; + expect(event.oldRange).toEqual(range); + expect(event.newRange).toEqual([[3, 16], [4, 3]]); + expect(event.oldText).toBe(oldText); + expect(event.newText).toBe("foo\nbar"); + })); + + it("allows a change to be undone safely from an ::onDidChange callback", function() { + buffer.onDidChange(() => buffer.undo()); + buffer.setTextInRange([[0, 0], [0, 0]], "hello"); + expect(buffer.lineForRow(0)).toBe("var quicksort = function () {"); + }); + }); + + describe("::setText(text)", function() { + beforeEach(function() { + const filePath = require.resolve('./fixtures/sample.js'); + buffer = TextBuffer.loadSync(filePath); + }); + + describe("when the buffer contains newlines", () => it("changes the entire contents of the buffer and emits a change event", function() { + const lastRow = buffer.getLastRow(); + const expectedPreRange = [[0, 0], [lastRow, buffer.lineForRow(lastRow).length]]; + const changeHandler = jasmine.createSpy('changeHandler'); + buffer.onDidChange(changeHandler); + + const newText = "I know you are.\nBut what am I?"; + buffer.setText(newText); + + expect(buffer.getText()).toBe(newText); + expect(changeHandler).toHaveBeenCalled(); + + const [event] = changeHandler.calls.allArgs()[0]; + expect(event.newText).toBe(newText); + expect(event.oldRange).toEqual(expectedPreRange); + expect(event.newRange).toEqual([[0, 0], [1, 14]]); + })); + + describe("with windows newlines", () => it("changes the entire contents of the buffer", function() { + buffer = new TextBuffer("first\r\nlast"); + const lastRow = buffer.getLastRow(); + const expectedPreRange = [[0, 0], [lastRow, buffer.lineForRow(lastRow).length]]; + const changeHandler = jasmine.createSpy('changeHandler'); + buffer.onDidChange(changeHandler); + + const newText = "new first\r\nnew last"; + buffer.setText(newText); + + expect(buffer.getText()).toBe(newText); + expect(changeHandler).toHaveBeenCalled(); + + const [event] = changeHandler.calls.allArgs()[0]; + expect(event.newText).toBe(newText); + expect(event.oldRange).toEqual(expectedPreRange); + expect(event.newRange).toEqual([[0, 0], [1, 8]]); + })); +}); + + describe("::setTextViaDiff(text)", function() { + beforeEach(function(done) { + const filePath = require.resolve('./fixtures/sample.js'); + TextBuffer.load(filePath).then(function(result) { + buffer = result; + done(); + }); + }); + + it("can change the entire contents of the buffer when there are no newlines", function() { + buffer.setText('BUFFER CHANGE'); + const newText = 'DISK CHANGE'; + buffer.setTextViaDiff(newText); + expect(buffer.getText()).toBe(newText); + }); + + it("can change a buffer that contains lone carriage returns", function() { + const oldText = 'one\rtwo\nthree\rfour\n'; + const newText = 'one\rtwo and\nthree\rfour\n'; + buffer.setText(oldText); + buffer.setTextViaDiff(newText); + expect(buffer.getText()).toBe(newText); + buffer.undo(); + expect(buffer.getText()).toBe(oldText); + }); + + describe("with standard newlines", function() { + it("can change the entire contents of the buffer with no newline at the end", function() { + const newText = "I know you are.\nBut what am I?"; + buffer.setTextViaDiff(newText); + expect(buffer.getText()).toBe(newText); + }); + + it("can change the entire contents of the buffer with a newline at the end", function() { + const newText = "I know you are.\nBut what am I?\n"; + buffer.setTextViaDiff(newText); + expect(buffer.getText()).toBe(newText); + }); + + it("can change a few lines at the beginning in the buffer", function() { + const newText = buffer.getText().replace(/function/g, 'omgwow'); + buffer.setTextViaDiff(newText); + expect(buffer.getText()).toBe(newText); + }); + + it("can change a few lines in the middle of the buffer", function() { + const newText = buffer.getText().replace(/shift/g, 'omgwow'); + buffer.setTextViaDiff(newText); + expect(buffer.getText()).toBe(newText); + }); + + it("can adds a newline at the end", function() { + const newText = buffer.getText() + '\n'; + buffer.setTextViaDiff(newText); + expect(buffer.getText()).toBe(newText); + }); + }); + + describe("with windows newlines", function() { + beforeEach(() => buffer.setText(buffer.getText().replace(/\n/g, '\r\n'))); + + it("adds a newline at the end", function() { + const newText = buffer.getText() + '\r\n'; + buffer.setTextViaDiff(newText); + expect(buffer.getText()).toBe(newText); + }); + + it("changes the entire contents of the buffer with smaller content with no newline at the end", function() { + const newText = "I know you are.\r\nBut what am I?"; + buffer.setTextViaDiff(newText); + expect(buffer.getText()).toBe(newText); + }); + + it("changes the entire contents of the buffer with smaller content with newline at the end", function() { + const newText = "I know you are.\r\nBut what am I?\r\n"; + buffer.setTextViaDiff(newText); + expect(buffer.getText()).toBe(newText); + }); + + it("changes a few lines at the beginning in the buffer", function() { + const newText = buffer.getText().replace(/function/g, 'omgwow'); + buffer.setTextViaDiff(newText); + expect(buffer.getText()).toBe(newText); + }); + + it("changes a few lines in the middle of the buffer", function() { + const newText = buffer.getText().replace(/shift/g, 'omgwow'); + buffer.setTextViaDiff(newText); + expect(buffer.getText()).toBe(newText); + }); + }); + }); + + describe("::getTextInRange(range)", function() { + beforeEach(function(done) { + const filePath = require.resolve('./fixtures/sample.js'); + TextBuffer.load(filePath).then(function(result) { + buffer = result; + done(); + }); + }); + + describe("when range is empty", () => it("returns an empty string", function() { + const range = [[1, 1], [1, 1]]; + expect(buffer.getTextInRange(range)).toBe(""); + })); + + describe("when range spans one line", () => it("returns characters in range", function() { + let range = [[2, 8], [2, 13]]; + expect(buffer.getTextInRange(range)).toBe("items"); + + const lineLength = buffer.lineForRow(2).length; + range = [[2, 0], [2, lineLength]]; + expect(buffer.getTextInRange(range)).toBe(" if (items.length <= 1) return items;"); + })); + + describe("when range spans multiple lines", () => it("returns characters in range (including newlines)", function() { + let lineLength = buffer.lineForRow(2).length; + let range = [[2, 0], [3, 0]]; + expect(buffer.getTextInRange(range)).toBe(" if (items.length <= 1) return items;\n"); + + lineLength = buffer.lineForRow(2).length; + range = [[2, 10], [4, 10]]; + expect(buffer.getTextInRange(range)).toBe("ems.length <= 1) return items;\n var pivot = items.shift(), current, left = [], right = [];\n while("); + })); + + describe("when the range starts before the start of the buffer", () => it("clips the range to the start of the buffer", () => expect(buffer.getTextInRange([[-Infinity, -Infinity], [0, Infinity]])).toBe(buffer.lineForRow(0)))); + + describe("when the range ends after the end of the buffer", () => it("clips the range to the end of the buffer", () => expect(buffer.getTextInRange([[12], [13, Infinity]])).toBe(buffer.lineForRow(12)))); + }); + + describe("::scan(regex, fn)", function() { + beforeEach(() => buffer = TextBuffer.loadSync(require.resolve('./fixtures/sample.js'))); + + it("calls the given function with the information about each match", function() { + const matches = []; + buffer.scan(/current/g, match => matches.push(match)); + expect(matches.length).toBe(5); + + expect(matches[0].matchText).toBe('current'); + expect(matches[0].range).toEqual([[3, 31], [3, 38]]); + expect(matches[0].lineText).toBe(' var pivot = items.shift(), current, left = [], right = [];'); + expect(matches[0].lineTextOffset).toBe(0); + expect(matches[0].leadingContextLines.length).toBe(0); + expect(matches[0].trailingContextLines.length).toBe(0); + + expect(matches[1].matchText).toBe('current'); + expect(matches[1].range).toEqual([[5, 6], [5, 13]]); + expect(matches[1].lineText).toBe(' current = items.shift();'); + expect(matches[1].lineTextOffset).toBe(0); + expect(matches[1].leadingContextLines.length).toBe(0); + expect(matches[1].trailingContextLines.length).toBe(0); + }); + + it("calls the given function with the information about each match including context lines", function() { + const matches = []; + buffer.scan(/current/g, {leadingContextLineCount: 1, trailingContextLineCount: 2}, match => matches.push(match)); + expect(matches.length).toBe(5); + + expect(matches[0].matchText).toBe('current'); + expect(matches[0].range).toEqual([[3, 31], [3, 38]]); + expect(matches[0].lineText).toBe(' var pivot = items.shift(), current, left = [], right = [];'); + expect(matches[0].lineTextOffset).toBe(0); + expect(matches[0].leadingContextLines.length).toBe(1); + expect(matches[0].leadingContextLines[0]).toBe(' if (items.length <= 1) return items;'); + expect(matches[0].trailingContextLines.length).toBe(2); + expect(matches[0].trailingContextLines[0]).toBe(' while(items.length > 0) {'); + expect(matches[0].trailingContextLines[1]).toBe(' current = items.shift();'); + + expect(matches[1].matchText).toBe('current'); + expect(matches[1].range).toEqual([[5, 6], [5, 13]]); + expect(matches[1].lineText).toBe(' current = items.shift();'); + expect(matches[1].lineTextOffset).toBe(0); + expect(matches[1].leadingContextLines.length).toBe(1); + expect(matches[1].leadingContextLines[0]).toBe(' while(items.length > 0) {'); + expect(matches[1].trailingContextLines.length).toBe(2); + expect(matches[1].trailingContextLines[0]).toBe(' current < pivot ? left.push(current) : right.push(current);'); + expect(matches[1].trailingContextLines[1]).toBe(' }'); + }); + }); + + describe("::backwardsScan(regex, fn)", function() { + beforeEach(() => buffer = TextBuffer.loadSync(require.resolve('./fixtures/sample.js'))); + + it("calls the given function with the information about each match in backwards order", function() { + const matches = []; + buffer.backwardsScan(/current/g, match => matches.push(match)); + expect(matches.length).toBe(5); + + expect(matches[0].matchText).toBe('current'); + expect(matches[0].range).toEqual([[6, 56], [6, 63]]); + expect(matches[0].lineText).toBe(' current < pivot ? left.push(current) : right.push(current);'); + expect(matches[0].lineTextOffset).toBe(0); + expect(matches[0].leadingContextLines.length).toBe(0); + expect(matches[0].trailingContextLines.length).toBe(0); + + expect(matches[1].matchText).toBe('current'); + expect(matches[1].range).toEqual([[6, 34], [6, 41]]); + expect(matches[1].lineText).toBe(' current < pivot ? left.push(current) : right.push(current);'); + expect(matches[1].lineTextOffset).toBe(0); + expect(matches[1].leadingContextLines.length).toBe(0); + expect(matches[1].trailingContextLines.length).toBe(0); + }); + }); + + describe("::scanInRange(range, regex, fn)", function() { + beforeEach(function() { + const filePath = require.resolve('./fixtures/sample.js'); + buffer = TextBuffer.loadSync(filePath); + }); + + describe("when given a regex with a ignore case flag", () => it("does a case-insensitive search", function() { + const matches = []; + buffer.scanInRange(/cuRRent/i, [[0, 0], [12, 0]], ({match, range}) => matches.push(match)); + expect(matches.length).toBe(1); + })); + + describe("when given a regex with no global flag", () => it("calls the iterator with the first match for the given regex in the given range", function() { + const matches = []; + const ranges = []; + buffer.scanInRange(/cu(rr)ent/, [[4, 0], [6, 44]], function({match, range}) { + matches.push(match); + ranges.push(range); + }); + + expect(matches.length).toBe(1); + expect(ranges.length).toBe(1); + + expect(matches[0][0]).toBe('current'); + expect(matches[0][1]).toBe('rr'); + expect(ranges[0]).toEqual([[5, 6], [5, 13]]); + })); + + describe("when given a regex with a global flag", () => it("calls the iterator with each match for the given regex in the given range", function() { + const matches = []; + const ranges = []; + buffer.scanInRange(/cu(rr)ent/g, [[4, 0], [6, 59]], function({match, range}) { + matches.push(match); + ranges.push(range); + }); + + expect(matches.length).toBe(3); + expect(ranges.length).toBe(3); + + expect(matches[0][0]).toBe('current'); + expect(matches[0][1]).toBe('rr'); + expect(ranges[0]).toEqual([[5, 6], [5, 13]]); + + expect(matches[1][0]).toBe('current'); + expect(matches[1][1]).toBe('rr'); + expect(ranges[1]).toEqual([[6, 6], [6, 13]]); + + expect(matches[2][0]).toBe('current'); + expect(matches[2][1]).toBe('rr'); + expect(ranges[2]).toEqual([[6, 34], [6, 41]]); + })); + + describe("when the last regex match exceeds the end of the range", function() { + describe("when the portion of the match within the range also matches the regex", () => it("calls the iterator with the truncated match", function() { + const matches = []; + const ranges = []; + buffer.scanInRange(/cu(r*)/g, [[4, 0], [6, 9]], function({match, range}) { + matches.push(match); + ranges.push(range); + }); + + expect(matches.length).toBe(2); + expect(ranges.length).toBe(2); + + expect(matches[0][0]).toBe('curr'); + expect(matches[0][1]).toBe('rr'); + expect(ranges[0]).toEqual([[5, 6], [5, 10]]); + + expect(matches[1][0]).toBe('cur'); + expect(matches[1][1]).toBe('r'); + expect(ranges[1]).toEqual([[6, 6], [6, 9]]); + })); + + describe("when the portion of the match within the range does not matches the regex", () => it("does not call the iterator with the truncated match", function() { + const matches = []; + const ranges = []; + buffer.scanInRange(/cu(r*)e/g, [[4, 0], [6, 9]], function({match, range}) { + matches.push(match); + ranges.push(range); + }); + + expect(matches.length).toBe(1); + expect(ranges.length).toBe(1); + + expect(matches[0][0]).toBe('curre'); + expect(matches[0][1]).toBe('rr'); + expect(ranges[0]).toEqual([[5, 6], [5, 11]]); + })); + }); + + describe("when the iterator calls the 'replace' control function with a replacement string", function() { + it("replaces each occurrence of the regex match with the string", function() { + const ranges = []; + buffer.scanInRange(/cu(rr)ent/g, [[4, 0], [6, 59]], function({range, replace}) { + ranges.push(range); + replace("foo"); + }); + + expect(ranges[0]).toEqual([[5, 6], [5, 13]]); + expect(ranges[1]).toEqual([[6, 6], [6, 13]]); + expect(ranges[2]).toEqual([[6, 30], [6, 37]]); + + expect(buffer.lineForRow(5)).toBe(' foo = items.shift();'); + expect(buffer.lineForRow(6)).toBe(' foo < pivot ? left.push(foo) : right.push(current);'); + }); + + it("allows the match to be replaced with the empty string", function() { + buffer.scanInRange(/current/g, [[4, 0], [6, 59]], ({replace}) => replace("")); + + expect(buffer.lineForRow(5)).toBe(' = items.shift();'); + expect(buffer.lineForRow(6)).toBe(' < pivot ? left.push() : right.push(current);'); + }); + }); + + describe("when the iterator calls the 'stop' control function", () => it("stops the traversal", function() { + const ranges = []; + buffer.scanInRange(/cu(rr)ent/g, [[4, 0], [6, 59]], function({range, stop}) { + ranges.push(range); + if (ranges.length === 2) { stop(); } + }); + + expect(ranges.length).toBe(2); + })); + + it("returns the same results as a regex match on a regular string", function() { + const regexps = [ + /\w+/g, // 1 word + /\w+\n\s*\w+/g, // 2 words separated by an newline (escape sequence) + RegExp("\\w+\n\\s*\w+", 'g'), // 2 words separated by a newline (literal) + /\w+\s+\w+/g, // 2 words separated by some whitespace + /\w+[^\w]+\w+/g, // 2 words separated by anything + /\w+\n\s*\w+\n\s*\w+/g, // 3 words separated by newlines (escape sequence) + RegExp("\\w+\n\\s*\\w+\n\\s*\\w+", 'g'), // 3 words separated by newlines (literal) + /\w+[^\w]+\w+[^\w]+\w+/g, // 3 words separated by anything + ]; + + let i = 0; + while (i < 20) { + var left; + var seed = Date.now(); + var random = new Random(seed); + + var text = buildRandomLines(random, 40); + buffer = new TextBuffer({text}); + buffer.backwardsScanChunkSize = random.intBetween(100, 1000); + + var range = getRandomBufferRange(random, buffer) + .union(getRandomBufferRange(random, buffer)) + .union(getRandomBufferRange(random, buffer)); + var regex = regexps[random(regexps.length)]; + + var expectedMatches = (left = buffer.getTextInRange(range).match(regex)) != null ? left : []; + if (!(expectedMatches.length > 0)) { continue; } + i++; + + var forwardRanges = []; + var forwardMatches = []; + buffer.scanInRange(regex, range, function({range, matchText}) { + forwardRanges.push(range); + forwardMatches.push(matchText); + }); + expect(forwardMatches).toEqual(expectedMatches, `Seed: ${seed}`); + + var backwardRanges = []; + var backwardMatches = []; + buffer.backwardsScanInRange(regex, range, function({range, matchText}) { + backwardRanges.push(range); + backwardMatches.push(matchText); + }); + expect(backwardMatches).toEqual(expectedMatches.reverse(), `Seed: ${seed}`); + } + }); + + it("does not return empty matches at the end of the range", function() { + const ranges = []; + buffer.scanInRange(/[ ]*/gm, [[0, 29], [1, 2]], ({range}) => ranges.push(range)); + expect(ranges).toEqual([[[0, 29], [0, 29]], [[1, 0], [1, 2]]]); + + ranges.length = 0; + buffer.scanInRange(/[ ]*/gm, [[1, 0], [1, 2]], ({range}) => ranges.push(range)); + expect(ranges).toEqual([[[1, 0], [1, 2]]]); + + ranges.length = 0; + buffer.scanInRange(/\s*/gm, [[0, 29], [1, 2]], ({range}) => ranges.push(range)); + expect(ranges).toEqual([[[0, 29], [1, 2]]]); + + ranges.length = 0; + buffer.scanInRange(/\s*/gm, [[1, 0], [1, 2]], ({range}) => ranges.push(range)); + expect(ranges).toEqual([[[1, 0], [1, 2]]]); + }); + + it("allows empty matches at the end of a range, when the range ends at column 0", function() { + const ranges = []; + buffer.scanInRange(/^[ ]*/gm, [[9, 0], [10, 0]], ({range}) => ranges.push(range)); + expect(ranges).toEqual([[[9, 0], [9, 2]], [[10, 0], [10, 0]]]); + + ranges.length = 0; + buffer.scanInRange(/^[ ]*/gm, [[10, 0], [10, 0]], ({range}) => ranges.push(range)); + expect(ranges).toEqual([[[10, 0], [10, 0]]]); + + ranges.length = 0; + buffer.scanInRange(/^\s*/gm, [[9, 0], [10, 0]], ({range}) => ranges.push(range)); + expect(ranges).toEqual([[[9, 0], [9, 2]], [[10, 0], [10, 0]]]); + + ranges.length = 0; + buffer.scanInRange(/^\s*/gm, [[10, 0], [10, 0]], ({range}) => ranges.push(range)); + expect(ranges).toEqual([[[10, 0], [10, 0]]]); + + ranges.length = 0; + buffer.scanInRange(/^\s*/gm, [[11, 0], [12, 0]], ({range}) => ranges.push(range)); + expect(ranges).toEqual([[[11, 0], [11, 2]], [[12, 0], [12, 0]]]); + }); + + it("handles multi-line patterns", function() { + const matchStrings = []; + + // The '\s' character class + buffer.scan(/{\s+var/, ({matchText}) => matchStrings.push(matchText)); + expect(matchStrings).toEqual(['{\n var']); + + // A literal newline character + matchStrings.length = 0; + buffer.scan(RegExp("{\n var"), ({matchText}) => matchStrings.push(matchText)); + expect(matchStrings).toEqual(['{\n var']); + + // A '\n' escape sequence + matchStrings.length = 0; + buffer.scan(/{\n var/, ({matchText}) => matchStrings.push(matchText)); + expect(matchStrings).toEqual(['{\n var']); + + // A negated character class in the middle of the pattern + matchStrings.length = 0; + buffer.scan(/{[^a] var/, ({matchText}) => matchStrings.push(matchText)); + expect(matchStrings).toEqual(['{\n var']); + + // A negated character class at the beginning of the pattern + matchStrings.length = 0; + buffer.scan(/[^a] var/, ({matchText}) => matchStrings.push(matchText)); + expect(matchStrings).toEqual(['\n var']); + }); + }); + + describe("::find(regex)", () => it("resolves with the first range that matches the given regex", function(done) { + buffer = new TextBuffer('abc\ndefghi'); + buffer.find(/\wf\w*/).then(function(range) { + expect(range).toEqual(Range(Point(1, 1), Point(1, 6))); + done(); + }); + })); + + describe("::findAllSync(regex)", () => it("returns all the ranges that match the given regex", function() { + buffer = new TextBuffer('abc\ndefghi'); + expect(buffer.findAllSync(/[bf]\w+/)).toEqual([ + Range(Point(0, 1), Point(0, 3)), + Range(Point(1, 2), Point(1, 6)), + ]); + })); + + describe("::findAndMarkAllInRangeSync(markerLayer, regex, range, options)", () => it("populates the marker index with the matching ranges", function() { + buffer = new TextBuffer('abc def\nghi jkl\n'); + const layer = buffer.addMarkerLayer(); + let markers = buffer.findAndMarkAllInRangeSync(layer, /\w+/g, [[0, 1], [1, 6]], {invalidate: 'inside'}); + expect(markers.map(marker => marker.getRange())).toEqual([ + [[0, 1], [0, 3]], + [[0, 4], [0, 7]], + [[1, 0], [1, 3]], + [[1, 4], [1, 6]] + ]); + expect(markers[0].getInvalidationStrategy()).toBe('inside'); + expect(markers[0].isExclusive()).toBe(true); + + markers = buffer.findAndMarkAllInRangeSync(layer, /abc/g, [[0, 0], [1, 0]], {invalidate: 'touch'}); + expect(markers.map(marker => marker.getRange())).toEqual([ + [[0, 0], [0, 3]] + ]); + expect(markers[0].getInvalidationStrategy()).toBe('touch'); + expect(markers[0].isExclusive()).toBe(false); + })); + + describe("::findWordsWithSubsequence and ::findWordsWithSubsequenceInRange", function() { + it('resolves with all words matching the given query', function(done) { + buffer = new TextBuffer('banana bandana ban_ana bandaid band bNa\nbanana'); + buffer.findWordsWithSubsequence('bna', '_', 4).then(function(results) { + const expected = [ + { + score: 29, + matchIndices: [0, 1, 2], + positions: [{row: 0, column: 36}], + word: "bNa" + }, + { + score: 16, + matchIndices: [0, 2, 4], + positions: [{row: 0, column: 15}], + word: "ban_ana" + }, + { + score: 12, + matchIndices: [0, 2, 3], + positions: [{row: 0, column: 0}, {row: 1, column: 0}], + word: "banana" + }, + { + score: 7, + matchIndices: [0, 5, 6], + positions: [{row: 0, column: 7}], + word: "bandana" + } + ]; + // JSON serialization doesn't work properly with `SubsequenceMatch` + // results, so we use another strategy to test deep equality. + for (var i in results) { + var result = results[i]; + for (var value = 0; value < result.length; value++) { + var prop = result[value]; + expect(value).toEqual(expected[i][prop]); + } + } + done(); + }); + }); + + it('resolves with all words matching the given query and range', function(done) { + const range = {start: {column: 0, row: 0}, end: {column: 22, row: 0}}; + buffer = new TextBuffer('banana bandana ban_ana bandaid band bNa\nbanana'); + buffer.findWordsWithSubsequenceInRange('bna', '_', 3, range).then(function(results) { + const expected = [ + { + score: 16, + matchIndices: [0, 2, 4], + positions: [{row: 0, column: 15}], + word: "ban_ana" + }, + { + score: 12, + matchIndices: [0, 2, 3], + positions: [{row: 0, column: 0}], + word: "banana" + }, + { + score: 7, + matchIndices: [0, 5, 6], + positions: [{row: 0, column: 7}], + word: "bandana" + } + ]; + // JSON serialization doesn't work properly with `SubsequenceMatch` + // results, so we use another strategy to test deep equality. + for (var i in results) { + var result = results[i]; + for (var value = 0; value < result.length; value++) { + var prop = result[value]; + expect(value).toEqual(expected[i][prop]); + } + } + done(); + }); + }); + }); + + describe("::backwardsScanInRange(range, regex, fn)", function() { + beforeEach(function() { + const filePath = require.resolve('./fixtures/sample.js'); + buffer = TextBuffer.loadSync(filePath); + }); + + describe("when given a regex with no global flag", () => it("calls the iterator with the last match for the given regex in the given range", function() { + const matches = []; + const ranges = []; + buffer.backwardsScanInRange(/cu(rr)ent/, [[4, 0], [6, 44]], function({match, range}) { + matches.push(match); + ranges.push(range); + }); + + expect(matches.length).toBe(1); + expect(ranges.length).toBe(1); + + expect(matches[0][0]).toBe('current'); + expect(matches[0][1]).toBe('rr'); + expect(ranges[0]).toEqual([[6, 34], [6, 41]]); + })); + + describe("when given a regex with a global flag", () => it("calls the iterator with each match for the given regex in the given range, starting with the last match", function() { + const matches = []; + const ranges = []; + buffer.backwardsScanInRange(/cu(rr)ent/g, [[4, 0], [6, 59]], function({match, range}) { + matches.push(match); + ranges.push(range); + }); + + expect(matches.length).toBe(3); + expect(ranges.length).toBe(3); + + expect(matches[0][0]).toBe('current'); + expect(matches[0][1]).toBe('rr'); + expect(ranges[0]).toEqual([[6, 34], [6, 41]]); + + expect(matches[1][0]).toBe('current'); + expect(matches[1][1]).toBe('rr'); + expect(ranges[1]).toEqual([[6, 6], [6, 13]]); + + expect(matches[2][0]).toBe('current'); + expect(matches[2][1]).toBe('rr'); + expect(ranges[2]).toEqual([[5, 6], [5, 13]]); + })); + + describe("when the last regex match starts at the beginning of the range", () => it("calls the iterator with the match", function() { + let matches = []; + let ranges = []; + buffer.scanInRange(/quick/g, [[0, 4], [2, 0]], function({match, range}) { + matches.push(match); + ranges.push(range); + }); + + expect(matches.length).toBe(1); + expect(ranges.length).toBe(1); + + expect(matches[0][0]).toBe('quick'); + expect(ranges[0]).toEqual([[0, 4], [0, 9]]); + + matches = []; + ranges = []; + buffer.scanInRange(/^/, [[0, 0], [2, 0]], function({match, range}) { + matches.push(match); + ranges.push(range); + }); + + expect(matches.length).toBe(1); + expect(ranges.length).toBe(1); + + expect(matches[0][0]).toBe(""); + expect(ranges[0]).toEqual([[0, 0], [0, 0]]); + })); + + describe("when the first regex match exceeds the end of the range", function() { + describe("when the portion of the match within the range also matches the regex", () => it("calls the iterator with the truncated match", function() { + const matches = []; + const ranges = []; + buffer.backwardsScanInRange(/cu(r*)/g, [[4, 0], [6, 9]], function({match, range}) { + matches.push(match); + ranges.push(range); + }); + + expect(matches.length).toBe(2); + expect(ranges.length).toBe(2); + + expect(matches[0][0]).toBe('cur'); + expect(matches[0][1]).toBe('r'); + expect(ranges[0]).toEqual([[6, 6], [6, 9]]); + + expect(matches[1][0]).toBe('curr'); + expect(matches[1][1]).toBe('rr'); + expect(ranges[1]).toEqual([[5, 6], [5, 10]]); + })); + + describe("when the portion of the match within the range does not matches the regex", () => it("does not call the iterator with the truncated match", function() { + const matches = []; + const ranges = []; + buffer.backwardsScanInRange(/cu(r*)e/g, [[4, 0], [6, 9]], function({match, range}) { + matches.push(match); + ranges.push(range); + }); + + expect(matches.length).toBe(1); + expect(ranges.length).toBe(1); + + expect(matches[0][0]).toBe('curre'); + expect(matches[0][1]).toBe('rr'); + expect(ranges[0]).toEqual([[5, 6], [5, 11]]); + })); + }); + + describe("when the iterator calls the 'replace' control function with a replacement string", () => it("replaces each occurrence of the regex match with the string", function() { + const ranges = []; + buffer.backwardsScanInRange(/cu(rr)ent/g, [[4, 0], [6, 59]], function({range, replace}) { + ranges.push(range); + if (!range.start.isEqual([6, 6])) { replace("foo"); } + }); + + expect(ranges[0]).toEqual([[6, 34], [6, 41]]); + expect(ranges[1]).toEqual([[6, 6], [6, 13]]); + expect(ranges[2]).toEqual([[5, 6], [5, 13]]); + + expect(buffer.lineForRow(5)).toBe(' foo = items.shift();'); + expect(buffer.lineForRow(6)).toBe(' current < pivot ? left.push(foo) : right.push(current);'); + })); + + describe("when the iterator calls the 'stop' control function", () => it("stops the traversal", function() { + const ranges = []; + buffer.backwardsScanInRange(/cu(rr)ent/g, [[4, 0], [6, 59]], function({range, stop}) { + ranges.push(range); + if (ranges.length === 2) { stop(); } + }); + + expect(ranges.length).toBe(2); + expect(ranges[0]).toEqual([[6, 34], [6, 41]]); + expect(ranges[1]).toEqual([[6, 6], [6, 13]]); + })); + + describe("when called with a random range", () => { + it("returns the same results as ::scanInRange, but in the opposite order", () => { + for (let i = 1; i < 50; i++) { + var seed = Date.now(); + var random = new Random(seed); + + buffer.backwardsScanChunkSize = random.intBetween(1, 80); + + var [startRow, endRow] = [random(buffer.getLineCount()), random(buffer.getLineCount())].sort(); + var startColumn = random(buffer.lineForRow(startRow).length); + var endColumn = random(buffer.lineForRow(endRow).length); + var range = [[startRow, startColumn], [endRow, endColumn]]; + + var regex = [ + /\w/g, + /\w{2}/g, + /\w{3}/g, + /.{5}/g + ][random(4)]; + + if (random(2) > 0) { + var forwardRanges = []; + var backwardRanges = []; + var forwardMatches = []; + var backwardMatches = []; + + buffer.scanInRange(regex, range, function({range, matchText}) { + forwardMatches.push(matchText); + forwardRanges.push(range); + }); + + buffer.backwardsScanInRange(regex, range, function({range, matchText}) { + backwardMatches.unshift(matchText); + backwardRanges.unshift(range); + }); + + expect(backwardRanges).toEqual(forwardRanges, `Seed: ${seed}`); + expect(backwardMatches).toEqual(forwardMatches, `Seed: ${seed}`); + } else { + var referenceBuffer = new TextBuffer({text: buffer.getText()}); + referenceBuffer.scanInRange(regex, range, ({matchText, replace}) => replace(matchText + '.')); + + buffer.backwardsScanInRange(regex, range, ({matchText, replace}) => replace(matchText + '.')); + + expect(buffer.getText()).toBe(referenceBuffer.getText(), `Seed: ${seed}`); + } + } + }); + }); + + it("does not return empty matches at the end of the range", function() { + const ranges = []; + + buffer.backwardsScanInRange(/[ ]*/gm, [[1, 0], [1, 2]], ({range}) => ranges.push(range)); + expect(ranges).toEqual([[[1, 0], [1, 2]]]); + + ranges.length = 0; + buffer.backwardsScanInRange(/[ ]*/m, [[0, 29], [1, 2]], ({range}) => ranges.push(range)); + expect(ranges).toEqual([[[1, 0], [1, 2]]]); + + ranges.length = 0; + buffer.backwardsScanInRange(/\s*/gm, [[1, 0], [1, 2]], ({range}) => ranges.push(range)); + expect(ranges).toEqual([[[1, 0], [1, 2]]]); + + ranges.length = 0; + buffer.backwardsScanInRange(/\s*/m, [[0, 29], [1, 2]], ({range}) => ranges.push(range)); + expect(ranges).toEqual([[[0, 29], [1, 2]]]); + }); + + it("allows empty matches at the end of a range, when the range ends at column 0", function() { + const ranges = []; + buffer.backwardsScanInRange(/^[ ]*/gm, [[9, 0], [10, 0]], ({range}) => ranges.push(range)); + expect(ranges).toEqual([[[10, 0], [10, 0]], [[9, 0], [9, 2]]]); + + ranges.length = 0; + buffer.backwardsScanInRange(/^[ ]*/gm, [[10, 0], [10, 0]], ({range}) => ranges.push(range)); + expect(ranges).toEqual([[[10, 0], [10, 0]]]); + + ranges.length = 0; + buffer.backwardsScanInRange(/^\s*/gm, [[9, 0], [10, 0]], ({range}) => ranges.push(range)); + expect(ranges).toEqual([[[10, 0], [10, 0]], [[9, 0], [9, 2]]]); + + ranges.length = 0; + buffer.backwardsScanInRange(/^\s*/gm, [[10, 0], [10, 0]], ({range}) => ranges.push(range)); + expect(ranges).toEqual([[[10, 0], [10, 0]]]); + }); + }); + + describe("::characterIndexForPosition(position)", function() { + beforeEach(function() { + const filePath = require.resolve('./fixtures/sample.js'); + buffer = TextBuffer.loadSync(filePath); + }); + + it("returns the total number of characters that precede the given position", function() { + expect(buffer.characterIndexForPosition([0, 0])).toBe(0); + expect(buffer.characterIndexForPosition([0, 1])).toBe(1); + expect(buffer.characterIndexForPosition([0, 29])).toBe(29); + expect(buffer.characterIndexForPosition([1, 0])).toBe(30); + expect(buffer.characterIndexForPosition([2, 0])).toBe(61); + expect(buffer.characterIndexForPosition([12, 2])).toBe(408); + expect(buffer.characterIndexForPosition([Infinity])).toBe(408); + }); + + describe("when the buffer contains crlf line endings", () => it("returns the total number of characters that precede the given position", function() { + buffer.setText("line1\r\nline2\nline3\r\nline4"); + expect(buffer.characterIndexForPosition([1])).toBe(7); + expect(buffer.characterIndexForPosition([2])).toBe(13); + expect(buffer.characterIndexForPosition([3])).toBe(20); + })); + }); + + describe("::positionForCharacterIndex(position)", function() { + beforeEach(function() { + const filePath = require.resolve('./fixtures/sample.js'); + buffer = TextBuffer.loadSync(filePath); + }); + + it("returns the position based on character index", function() { + expect(buffer.positionForCharacterIndex(0)).toEqual([0, 0]); + expect(buffer.positionForCharacterIndex(1)).toEqual([0, 1]); + expect(buffer.positionForCharacterIndex(29)).toEqual([0, 29]); + expect(buffer.positionForCharacterIndex(30)).toEqual([1, 0]); + expect(buffer.positionForCharacterIndex(61)).toEqual([2, 0]); + expect(buffer.positionForCharacterIndex(408)).toEqual([12, 2]); + }); + + describe("when the buffer contains crlf line endings", () => it("returns the position based on character index", function() { + buffer.setText("line1\r\nline2\nline3\r\nline4"); + expect(buffer.positionForCharacterIndex(7)).toEqual([1, 0]); + expect(buffer.positionForCharacterIndex(13)).toEqual([2, 0]); + expect(buffer.positionForCharacterIndex(20)).toEqual([3, 0]); + })); +}); + + describe("::isEmpty()", function() { + beforeEach(function() { + const filePath = require.resolve('./fixtures/sample.js'); + buffer = TextBuffer.loadSync(filePath); + }); + + it("returns true for an empty buffer", function() { + buffer.setText(''); + expect(buffer.isEmpty()).toBeTruthy(); + }); + + it("returns false for a non-empty buffer", function() { + buffer.setText('a'); + expect(buffer.isEmpty()).toBeFalsy(); + buffer.setText('a\nb\nc'); + expect(buffer.isEmpty()).toBeFalsy(); + buffer.setText('\n'); + expect(buffer.isEmpty()).toBeFalsy(); + }); + }); + + describe("::hasAstral()", function() { + it("returns true for buffers containing surrogate pairs", () => expect(new TextBuffer('hooray 😄').hasAstral()).toBeTruthy()); + + it("returns false for buffers that do not contain surrogate pairs", () => expect(new TextBuffer('nope').hasAstral()).toBeFalsy()); + }); + + describe("::onWillChange(callback)", () => it("notifies observers before a transaction, an undo or a redo", function() { + let changeCount = 0; + let expectedText = ''; + + buffer = new TextBuffer(); + const checkpoint = buffer.createCheckpoint(); + + buffer.onWillChange(function(change) { + expect(buffer.getText()).toBe(expectedText); + changeCount++; + }); + + buffer.append('a'); + expect(changeCount).toBe(1); + expectedText = 'a'; + + buffer.transact(function() { + buffer.append('b'); + buffer.append('c'); + }); + expect(changeCount).toBe(2); + expectedText = 'abc'; + + // Empty transactions do not cause onWillChange listeners to be called + buffer.transact(function() {}); + expect(changeCount).toBe(2); + + buffer.undo(); + expect(changeCount).toBe(3); + expectedText = 'a'; + + buffer.redo(); + expect(changeCount).toBe(4); + expectedText = 'abc'; + + buffer.revertToCheckpoint(checkpoint); + expect(changeCount).toBe(5); + })); + + describe("::onDidChange(callback)", function() { + beforeEach(function() { + const filePath = require.resolve('./fixtures/sample.js'); + buffer = TextBuffer.loadSync(filePath); + }); + + it("notifies observers after a transaction, an undo or a redo", function() { + let textChanges = []; + buffer.onDidChange(({changes}) => textChanges.push(...changes || [])); + + buffer.insert([0, 0], "abc"); + buffer.delete([[0, 0], [0, 1]]); + + assertChangesEqual(textChanges, [ + { + oldRange: [[0, 0], [0, 0]], + newRange: [[0, 0], [0, 3]], + oldText: "", + newText: "abc" + }, + { + oldRange: [[0, 0], [0, 1]], + newRange: [[0, 0], [0, 0]], + oldText: "a", + newText: "" + } + ]); + + textChanges = []; + buffer.transact(function() { + buffer.insert([1, 0], "v"); + buffer.insert([1, 1], "x"); + buffer.insert([1, 2], "y"); + buffer.insert([2, 3], "zw"); + buffer.delete([[2, 3], [2, 4]]); + }); + + assertChangesEqual(textChanges, [ + { + oldRange: [[1, 0], [1, 0]], + newRange: [[1, 0], [1, 3]], + oldText: "", + newText: "vxy", + }, + { + oldRange: [[2, 3], [2, 3]], + newRange: [[2, 3], [2, 4]], + oldText: "", + newText: "w", + } + ]); + + textChanges = []; + buffer.undo(); + assertChangesEqual(textChanges, [ + { + oldRange: [[1, 0], [1, 3]], + newRange: [[1, 0], [1, 0]], + oldText: "vxy", + newText: "", + }, + { + oldRange: [[2, 3], [2, 4]], + newRange: [[2, 3], [2, 3]], + oldText: "w", + newText: "", + } + ]); + + textChanges = []; + buffer.redo(); + assertChangesEqual(textChanges, [ + { + oldRange: [[1, 0], [1, 0]], + newRange: [[1, 0], [1, 3]], + oldText: "", + newText: "vxy", + }, + { + oldRange: [[2, 3], [2, 3]], + newRange: [[2, 3], [2, 4]], + oldText: "", + newText: "w", + } + ]); + + textChanges = []; + buffer.transact(() => buffer.transact(() => buffer.insert([0, 0], "j"))); + + // we emit only one event for nested transactions + assertChangesEqual(textChanges, [ + { + oldRange: [[0, 0], [0, 0]], + newRange: [[0, 0], [0, 1]], + oldText: "", + newText: "j", + } + ]); + }); + + it("doesn't notify observers after an empty transaction", function() { + const didChangeTextSpy = jasmine.createSpy(); + buffer.onDidChange(didChangeTextSpy); + buffer.transact(function() {}); + expect(didChangeTextSpy).not.toHaveBeenCalled(); + }); + + it("doesn't throw an error when clearing the undo stack within a transaction", function() { + let didChangeTextSpy; + buffer.onDidChange(didChangeTextSpy = jasmine.createSpy()); + expect(() => buffer.transact(() => buffer.clearUndoStack())).not.toThrowError(); + expect(didChangeTextSpy).not.toHaveBeenCalled(); + }); + }); + + describe("::onDidStopChanging(callback)", function() { + let delay, didStopChangingCallback; + + const wait = (milliseconds, callback) => setTimeout(callback, milliseconds); + + beforeEach(function() { + const filePath = require.resolve('./fixtures/sample.js'); + buffer = TextBuffer.loadSync(filePath); + delay = buffer.stoppedChangingDelay; + didStopChangingCallback = jasmine.createSpy("didStopChangingCallback"); + buffer.onDidStopChanging(didStopChangingCallback); + }); + + it("notifies observers after a delay passes following changes", function(done) { + buffer.insert([0, 0], 'a'); + expect(didStopChangingCallback).not.toHaveBeenCalled(); + + wait(delay / 2, function() { + buffer.transact(() => buffer.transact(function() { + buffer.insert([0, 0], 'b'); + buffer.insert([1, 0], 'c'); + buffer.insert([1, 1], 'd'); + })); + expect(didStopChangingCallback).not.toHaveBeenCalled(); + + wait(delay / 2, function() { + expect(didStopChangingCallback).not.toHaveBeenCalled(); + + wait(delay, function() { + expect(didStopChangingCallback).toHaveBeenCalled(); + assertChangesEqual(didStopChangingCallback.calls.mostRecent().args[0].changes, [ + { + oldRange: [[0, 0], [0, 0]], + newRange: [[0, 0], [0, 2]], + oldText: "", + newText: "ba", + }, + { + oldRange: [[1, 0], [1, 0]], + newRange: [[1, 0], [1, 2]], + oldText: "", + newText: "cd", + } + ]); + + didStopChangingCallback.calls.reset(); + buffer.undo(); + buffer.undo(); + wait(delay * 2, function() { + expect(didStopChangingCallback).toHaveBeenCalled(); + assertChangesEqual(didStopChangingCallback.calls.mostRecent().args[0].changes, [ + { + oldRange: [[0, 0], [0, 2]], + newRange: [[0, 0], [0, 0]], + oldText: "ba", + newText: "", + }, + { + oldRange: [[1, 0], [1, 2]], + newRange: [[1, 0], [1, 0]], + oldText: "cd", + newText: "", + }, + ]); + done(); + }); + }); + }); + }); + }); + + it("provides the correct changes when the buffer is mutated in the onDidChange callback", function(done) { + buffer.onDidChange(function({changes}) { + switch (changes[0].newText) { + case 'a': + buffer.insert(changes[0].newRange.end, 'b'); + break; + case 'b': + buffer.insert(changes[0].newRange.end, 'c'); + break; + case 'c': + buffer.insert(changes[0].newRange.end, 'd'); + break; + } + }); + + buffer.insert([0, 0], 'a'); + + wait(delay * 2, function() { + expect(didStopChangingCallback).toHaveBeenCalled(); + assertChangesEqual(didStopChangingCallback.calls.mostRecent().args[0].changes, [ + { + oldRange: [[0, 0], [0, 0]], + newRange: [[0, 0], [0, 4]], + oldText: "", + newText: "abcd", + } + ]); + done(); + }); + }); + }); + + describe("::append(text)", function() { + beforeEach(function() { + const filePath = require.resolve('./fixtures/sample.js'); + buffer = TextBuffer.loadSync(filePath); + }); + + it("adds text to the end of the buffer", function() { + buffer.setText(""); + buffer.append("a"); + expect(buffer.getText()).toBe("a"); + buffer.append("b\nc"); + expect(buffer.getText()).toBe("ab\nc"); + }); + }); + + describe("::setLanguageMode", function() { + it("destroys the previous language mode", function() { + buffer = new TextBuffer(); + + const languageMode1 = { + alive: true, + destroy() { this.alive = false; }, + onDidChangeHighlighting() { return {dispose() {}}; } + }; + + const languageMode2 = { + alive: true, + destroy() { this.alive = false; }, + onDidChangeHighlighting() { return {dispose() {}}; } + }; + + buffer.setLanguageMode(languageMode1); + expect(languageMode1.alive).toBe(true); + expect(languageMode2.alive).toBe(true); + + buffer.setLanguageMode(languageMode2); + expect(languageMode1.alive).toBe(false); + expect(languageMode2.alive).toBe(true); + + buffer.destroy(); + expect(languageMode1.alive).toBe(false); + expect(languageMode2.alive).toBe(false); + }); + + it("notifies ::onDidChangeLanguageMode observers when the language mode changes", function() { + buffer = new TextBuffer(); + expect(buffer.getLanguageMode() instanceof NullLanguageMode).toBe(true); + + const events = []; + buffer.onDidChangeLanguageMode((newMode, oldMode) => events.push({newMode, oldMode})); + + const languageMode = { + onDidChangeHighlighting() { return {dispose() {}}; } + }; + + buffer.setLanguageMode(languageMode); + expect(buffer.getLanguageMode()).toBe(languageMode); + expect(events.length).toBe(1); + expect(events[0].newMode).toBe(languageMode); + expect(events[0].oldMode instanceof NullLanguageMode).toBe(true); + + buffer.setLanguageMode(null); + expect(buffer.getLanguageMode() instanceof NullLanguageMode).toBe(true); + expect(events.length).toBe(2); + expect(events[1].newMode).toBe(buffer.getLanguageMode()); + expect(events[1].oldMode).toBe(languageMode); + }); + }); + + describe("line ending support", function() { + beforeEach(function() { + const filePath = require.resolve('./fixtures/sample.js'); + buffer = TextBuffer.loadSync(filePath); + }); + + describe(".getText()", () => it("returns the text with the corrent line endings for each row", function() { + buffer.setText("a\r\nb\nc"); + expect(buffer.getText()).toBe("a\r\nb\nc"); + buffer.setText("a\r\nb\nc\n"); + expect(buffer.getText()).toBe("a\r\nb\nc\n"); + })); + + describe("when editing a line", () => it("preserves the existing line ending", function() { + buffer.setText("a\r\nb\nc"); + buffer.insert([0, 1], "1"); + expect(buffer.getText()).toBe("a1\r\nb\nc"); + })); + + describe("when inserting text with multiple lines", function() { + describe("when the current line has a line ending", () => it("uses the same line ending as the line where the text is inserted", function() { + buffer.setText("a\r\n"); + buffer.insert([0, 1], "hello\n1\n\n2"); + expect(buffer.getText()).toBe("ahello\r\n1\r\n\r\n2\r\n"); + })); + + describe("when the current line has no line ending (because it's the last line of the buffer)", function() { + describe("when the buffer contains only a single line", () => it("honors the line endings in the inserted text", function() { + buffer.setText("initialtext"); + buffer.append("hello\n1\r\n2\n"); + expect(buffer.getText()).toBe("initialtexthello\n1\r\n2\n"); + })); + + describe("when the buffer contains a preceding line", () => it("uses the line ending of the preceding line", function() { + buffer.setText("\ninitialtext"); + buffer.append("hello\n1\r\n2\n"); + expect(buffer.getText()).toBe("\ninitialtexthello\n1\n2\n"); + })); + }); + }); + + describe("::setPreferredLineEnding(lineEnding)", function() { + it("uses the given line ending when normalizing, rather than inferring one from the surrounding text", function() { + buffer = new TextBuffer({text: "a \r\n"}); + + expect(buffer.getPreferredLineEnding()).toBe(null); + buffer.append(" b \n"); + expect(buffer.getText()).toBe("a \r\n b \r\n"); + + buffer.setPreferredLineEnding("\n"); + expect(buffer.getPreferredLineEnding()).toBe("\n"); + buffer.append(" c \n"); + expect(buffer.getText()).toBe("a \r\n b \r\n c \n"); + + buffer.setPreferredLineEnding(null); + buffer.append(" d \r\n"); + expect(buffer.getText()).toBe("a \r\n b \r\n c \n d \n"); + }); + + it("persists across serialization and deserialization", function(done) { + const bufferA = new TextBuffer; + bufferA.setPreferredLineEnding("\r\n"); + + TextBuffer.deserialize(bufferA.serialize()).then(function(bufferB) { + expect(bufferB.getPreferredLineEnding()).toBe("\r\n"); + done(); + }); + }); + }); + }); +}); describe('when a buffer is already open', () => { const filePath = path.join(__dirname, 'fixtures', 'sample.js') @@ -30,3 +3294,16 @@ describe('when a buffer is already open', () => { }) }) }) + + +function assertChangesEqual(actualChanges, expectedChanges) { + expect(actualChanges.length).toBe(expectedChanges.length); + for (let i = 0; i < actualChanges.length; i++) { + var actualChange = actualChanges[i]; + var expectedChange = expectedChanges[i]; + expect(actualChange.oldRange).toEqual(expectedChange.oldRange); + expect(actualChange.newRange).toEqual(expectedChange.newRange); + expect(actualChange.oldText).toEqual(expectedChange.oldText); + expect(actualChange.newText).toEqual(expectedChange.newText); + } +} diff --git a/src/default-history-provider.coffee b/src/default-history-provider.coffee deleted file mode 100644 index 8679d9066b..0000000000 --- a/src/default-history-provider.coffee +++ /dev/null @@ -1,433 +0,0 @@ -{Patch} = require 'superstring' -MarkerLayer = require './marker-layer' -{traversal} = require './point-helpers' -{patchFromChanges} = require './helpers' - -SerializationVersion = 6 - -class Checkpoint - constructor: (@id, @snapshot, @isBarrier) -> - unless @snapshot? - global.atom?.assert(false, "Checkpoint created without snapshot") - @snapshot = {} - -class Transaction - constructor: (@markerSnapshotBefore, @patch, @markerSnapshotAfter, @groupingInterval=0) -> - @timestamp = Date.now() - - shouldGroupWith: (previousTransaction) -> - timeBetweenTransactions = @timestamp - previousTransaction.timestamp - timeBetweenTransactions < Math.min(@groupingInterval, previousTransaction.groupingInterval) - - groupWith: (previousTransaction) -> - new Transaction( - previousTransaction.markerSnapshotBefore, - Patch.compose([previousTransaction.patch, @patch]), - @markerSnapshotAfter, - @groupingInterval - ) - -# Manages undo/redo for {TextBuffer} -module.exports = -class DefaultHistoryProvider - constructor: (@buffer) -> - @maxUndoEntries = @buffer.maxUndoEntries - @nextCheckpointId = 1 - @undoStack = [] - @redoStack = [] - - createCheckpoint: (options) -> - checkpoint = new Checkpoint(@nextCheckpointId++, options?.markers, options?.isBarrier) - @undoStack.push(checkpoint) - checkpoint.id - - groupChangesSinceCheckpoint: (checkpointId, options) -> - deleteCheckpoint = options?.deleteCheckpoint ? false - markerSnapshotAfter = options?.markers - checkpointIndex = null - markerSnapshotBefore = null - patchesSinceCheckpoint = [] - - for entry, i in @undoStack by -1 - break if checkpointIndex? - - switch entry.constructor - when Checkpoint - if entry.id is checkpointId - checkpointIndex = i - markerSnapshotBefore = entry.snapshot - else if entry.isBarrier - return false - when Transaction - patchesSinceCheckpoint.unshift(entry.patch) - when Patch - patchesSinceCheckpoint.unshift(entry) - else - throw new Error("Unexpected undo stack entry type: #{entry.constructor.name}") - - if checkpointIndex? - composedPatches = Patch.compose(patchesSinceCheckpoint) - if patchesSinceCheckpoint.length > 0 - @undoStack.splice(checkpointIndex + 1) - @undoStack.push(new Transaction(markerSnapshotBefore, composedPatches, markerSnapshotAfter)) - if deleteCheckpoint - @undoStack.splice(checkpointIndex, 1) - composedPatches.getChanges() - else - false - - getChangesSinceCheckpoint: (checkpointId) -> - checkpointIndex = null - patchesSinceCheckpoint = [] - - for entry, i in @undoStack by -1 - break if checkpointIndex? - - switch entry.constructor - when Checkpoint - if entry.id is checkpointId - checkpointIndex = i - when Transaction - patchesSinceCheckpoint.unshift(entry.patch) - when Patch - patchesSinceCheckpoint.unshift(entry) - else - throw new Error("Unexpected undo stack entry type: #{entry.constructor.name}") - - if checkpointIndex? - Patch.compose(patchesSinceCheckpoint).getChanges() - else - null - - groupLastChanges: -> - markerSnapshotAfter = null - markerSnapshotBefore = null - patchesSinceCheckpoint = [] - - for entry, i in @undoStack by -1 - switch entry.constructor - when Checkpoint - return false if entry.isBarrier - when Transaction - if patchesSinceCheckpoint.length is 0 - markerSnapshotAfter = entry.markerSnapshotAfter - else if patchesSinceCheckpoint.length is 1 - markerSnapshotBefore = entry.markerSnapshotBefore - patchesSinceCheckpoint.unshift(entry.patch) - when Patch - patchesSinceCheckpoint.unshift(entry) - else - throw new Error("Unexpected undo stack entry type: #{entry.constructor.name}") - - if patchesSinceCheckpoint.length is 2 - composedPatch = Patch.compose(patchesSinceCheckpoint) - @undoStack.splice(i) - @undoStack.push(new Transaction(markerSnapshotBefore, composedPatch, markerSnapshotAfter)) - return true - return - - enforceUndoStackSizeLimit: -> - if @undoStack.length > @maxUndoEntries - @undoStack.splice(0, @undoStack.length - @maxUndoEntries) - - applyGroupingInterval: (groupingInterval) -> - topEntry = @undoStack[@undoStack.length - 1] - previousEntry = @undoStack[@undoStack.length - 2] - - if topEntry instanceof Transaction - topEntry.groupingInterval = groupingInterval - else - return - - return if groupingInterval is 0 - - if previousEntry instanceof Transaction and topEntry.shouldGroupWith(previousEntry) - @undoStack.splice(@undoStack.length - 2, 2, topEntry.groupWith(previousEntry)) - - pushChange: ({newStart, oldExtent, newExtent, oldText, newText}) -> - patch = new Patch - patch.splice(newStart, oldExtent, newExtent, oldText, newText) - @pushPatch(patch) - - pushPatch: (patch) -> - @undoStack.push(patch) - @clearRedoStack() - - undo: -> - snapshotBelow = null - patch = null - spliceIndex = null - - for entry, i in @undoStack by -1 - break if spliceIndex? - - switch entry.constructor - when Checkpoint - if entry.isBarrier - return false - when Transaction - snapshotBelow = entry.markerSnapshotBefore - patch = entry.patch.invert() - spliceIndex = i - when Patch - patch = entry.invert() - spliceIndex = i - else - throw new Error("Unexpected entry type when popping undoStack: #{entry.constructor.name}") - - if spliceIndex? - @redoStack.push(@undoStack.splice(spliceIndex).reverse()...) - { - textUpdates: patch.getChanges() - markers: snapshotBelow - } - else - false - - redo: -> - snapshotBelow = null - patch = null - spliceIndex = null - - for entry, i in @redoStack by -1 - break if spliceIndex? - - switch entry.constructor - when Checkpoint - if entry.isBarrier - throw new Error("Invalid redo stack state") - when Transaction - snapshotBelow = entry.markerSnapshotAfter - patch = entry.patch - spliceIndex = i - when Patch - patch = entry - spliceIndex = i - else - throw new Error("Unexpected entry type when popping redoStack: #{entry.constructor.name}") - - while @redoStack[spliceIndex - 1] instanceof Checkpoint - spliceIndex-- - - if spliceIndex? - @undoStack.push(@redoStack.splice(spliceIndex).reverse()...) - { - textUpdates: patch.getChanges() - markers: snapshotBelow - } - else - false - - revertToCheckpoint: (checkpointId) -> - snapshotBelow = null - spliceIndex = null - patchesSinceCheckpoint = [] - - for entry, i in @undoStack by -1 - break if spliceIndex? - - switch entry.constructor - when Checkpoint - if entry.id is checkpointId - snapshotBelow = entry.snapshot - spliceIndex = i - else if entry.isBarrier - return false - when Transaction - patchesSinceCheckpoint.push(entry.patch.invert()) - else - patchesSinceCheckpoint.push(entry.invert()) - - if spliceIndex? - @undoStack.splice(spliceIndex) - { - textUpdates: Patch.compose(patchesSinceCheckpoint).getChanges() - markers: snapshotBelow - } - else - false - - clear: -> - @clearUndoStack() - @clearRedoStack() - - clearUndoStack: -> - @undoStack.length = 0 - - clearRedoStack: -> - @redoStack.length = 0 - - toString: -> - output = '' - for entry in @undoStack - switch entry.constructor - when Checkpoint - output += "Checkpoint, " - when Transaction - output += "Transaction, " - when Patch - output += "Patch, " - else - output += "Unknown {#{JSON.stringify(entry)}}, " - '[' + output.slice(0, -2) + ']' - - serialize: (options) -> - version: SerializationVersion - nextCheckpointId: @nextCheckpointId - undoStack: @serializeStack(@undoStack, options) - redoStack: @serializeStack(@redoStack, options) - maxUndoEntries: @maxUndoEntries - - deserialize: (state) -> - return unless state.version is SerializationVersion - @nextCheckpointId = state.nextCheckpointId - @maxUndoEntries = state.maxUndoEntries - @undoStack = @deserializeStack(state.undoStack) - @redoStack = @deserializeStack(state.redoStack) - - getSnapshot: (maxEntries) -> - undoStackPatches = [] - undoStack = [] - for entry in @undoStack by -1 - switch entry.constructor - when Checkpoint - undoStack.unshift(snapshotFromCheckpoint(entry)) - when Transaction - undoStack.unshift(snapshotFromTransaction(entry)) - undoStackPatches.unshift(entry.patch) - - break if undoStack.length is maxEntries - - redoStack = [] - for entry in @redoStack by -1 - switch entry.constructor - when Checkpoint - redoStack.unshift(snapshotFromCheckpoint(entry)) - when Transaction - redoStack.unshift(snapshotFromTransaction(entry)) - - break if redoStack.length is maxEntries - - { - nextCheckpointId: @nextCheckpointId, - undoStackChanges: Patch.compose(undoStackPatches).getChanges(), - undoStack, - redoStack - } - - restoreFromSnapshot: ({@nextCheckpointId, undoStack, redoStack}) -> - @undoStack = undoStack.map (entry) -> - switch entry.type - when 'transaction' - transactionFromSnapshot(entry) - when 'checkpoint' - checkpointFromSnapshot(entry) - - @redoStack = redoStack.map (entry) -> - switch entry.type - when 'transaction' - transactionFromSnapshot(entry) - when 'checkpoint' - checkpointFromSnapshot(entry) - - ### - Section: Private - ### - - getCheckpointIndex: (checkpointId) -> - for entry, i in @undoStack by -1 - if entry instanceof Checkpoint and entry.id is checkpointId - return i - return null - - serializeStack: (stack, options) -> - for entry in stack - switch entry.constructor - when Checkpoint - { - type: 'checkpoint' - id: entry.id - snapshot: @serializeSnapshot(entry.snapshot, options) - isBarrier: entry.isBarrier - } - when Transaction - { - type: 'transaction' - markerSnapshotBefore: @serializeSnapshot(entry.markerSnapshotBefore, options) - markerSnapshotAfter: @serializeSnapshot(entry.markerSnapshotAfter, options) - patch: entry.patch.serialize().toString('base64') - } - when Patch - { - type: 'patch' - data: entry.serialize().toString('base64') - } - else - throw new Error("Unexpected undoStack entry type during serialization: #{entry.constructor.name}") - - deserializeStack: (stack) -> - for entry in stack - switch entry.type - when 'checkpoint' - new Checkpoint( - entry.id - MarkerLayer.deserializeSnapshot(entry.snapshot) - entry.isBarrier - ) - when 'transaction' - new Transaction( - MarkerLayer.deserializeSnapshot(entry.markerSnapshotBefore) - Patch.deserialize(Buffer.from(entry.patch, 'base64')) - MarkerLayer.deserializeSnapshot(entry.markerSnapshotAfter) - ) - when 'patch' - Patch.deserialize(Buffer.from(entry.data, 'base64')) - else - throw new Error("Unexpected undoStack entry type during deserialization: #{entry.type}") - - serializeSnapshot: (snapshot, options) -> - return unless options.markerLayers - - serializedLayerSnapshots = {} - for layerId, layerSnapshot of snapshot - continue unless @buffer.getMarkerLayer(layerId)?.persistent - serializedMarkerSnapshots = {} - for markerId, markerSnapshot of layerSnapshot - serializedMarkerSnapshot = Object.assign({}, markerSnapshot) - delete serializedMarkerSnapshot.marker - serializedMarkerSnapshots[markerId] = serializedMarkerSnapshot - serializedLayerSnapshots[layerId] = serializedMarkerSnapshots - serializedLayerSnapshots - -snapshotFromCheckpoint = (checkpoint) -> - { - type: 'checkpoint', - id: checkpoint.id, - markers: checkpoint.snapshot - } - -checkpointFromSnapshot = ({id, markers}) -> - new Checkpoint(id, markers, false) - -snapshotFromTransaction = (transaction) -> - changes = [] - for change in transaction.patch.getChanges() by 1 - changes.push({ - oldStart: change.oldStart, - oldEnd: change.oldEnd, - newStart: change.newStart, - newEnd: change.newEnd, - oldText: change.oldText, - newText: change.newText - }) - - { - type: 'transaction', - changes, - markersBefore: transaction.markerSnapshotBefore - markersAfter: transaction.markerSnapshotAfter - } - -transactionFromSnapshot = ({changes, markersBefore, markersAfter}) -> - # TODO: Return raw patch if there's no markersBefore && markersAfter - new Transaction(markersBefore, patchFromChanges(changes), markersAfter) diff --git a/src/default-history-provider.js b/src/default-history-provider.js new file mode 100644 index 0000000000..59af006be0 --- /dev/null +++ b/src/default-history-provider.js @@ -0,0 +1,593 @@ +const {Patch} = require('@pulsar-edit/superstring') +const MarkerLayer = require('./marker-layer'); +const {traversal} = require('./point-helpers'); +const {patchFromChanges} = require('./helpers'); + +const SerializationVersion = 6; + +class Checkpoint { + constructor(id, snapshot, isBarrier) { + this.id = id; + this.snapshot = snapshot; + this.isBarrier = isBarrier; + if (this.snapshot == null) { + global.atom?.assert(false, "Checkpoint created without snapshot"); + this.snapshot = {}; + } + } +} + +class Transaction { + constructor(markerSnapshotBefore, patch, markerSnapshotAfter, groupingInterval=0) { + this.markerSnapshotBefore = markerSnapshotBefore; + this.patch = patch; + this.markerSnapshotAfter = markerSnapshotAfter; + this.groupingInterval = groupingInterval; + this.timestamp = Date.now(); + } + + shouldGroupWith(previousTransaction) { + const timeBetweenTransactions = this.timestamp - previousTransaction.timestamp; + return timeBetweenTransactions < Math.min(this.groupingInterval, previousTransaction.groupingInterval); + } + + groupWith(previousTransaction) { + return new Transaction( + previousTransaction.markerSnapshotBefore, + Patch.compose([previousTransaction.patch, this.patch]), + this.markerSnapshotAfter, + this.groupingInterval + ); + } +} + +// Manages undo/redo for {TextBuffer} +class DefaultHistoryProvider { + constructor(buffer) { + this.buffer = buffer; + this.maxUndoEntries = this.buffer.maxUndoEntries; + this.nextCheckpointId = 1; + this.undoStack = []; + this.redoStack = []; + } + + createCheckpoint(options) { + const checkpoint = new Checkpoint(this.nextCheckpointId++, options?.markers, options?.isBarrier); + this.undoStack.push(checkpoint); + return checkpoint.id; + } + + groupChangesSinceCheckpoint(checkpointId, options) { + const deleteCheckpoint = options?.deleteCheckpoint ?? false; + const markerSnapshotAfter = options?.markers; + let checkpointIndex = null; + let markerSnapshotBefore = null; + const patchesSinceCheckpoint = []; + + for (let i = this.undoStack.length - 1; i >= 0; i--) { + const entry = this.undoStack[i]; + if (checkpointIndex != null) { break; } + + switch (entry.constructor) { + case Checkpoint: + if (entry.id === checkpointId) { + checkpointIndex = i; + markerSnapshotBefore = entry.snapshot; + } else if (entry.isBarrier) { + return false; + } + break; + case Transaction: + patchesSinceCheckpoint.unshift(entry.patch); + break; + case Patch: + patchesSinceCheckpoint.unshift(entry); + break; + default: + throw new Error(`Unexpected undo stack entry type: ${entry.constructor.name}`); + } + } + + if (checkpointIndex != null) { + const composedPatches = Patch.compose(patchesSinceCheckpoint); + if (patchesSinceCheckpoint.length > 0) { + this.undoStack.splice(checkpointIndex + 1); + this.undoStack.push(new Transaction(markerSnapshotBefore, composedPatches, markerSnapshotAfter)); + } + if (deleteCheckpoint) { + this.undoStack.splice(checkpointIndex, 1); + } + return composedPatches.getChanges(); + } else { + return false; + } + } + + getChangesSinceCheckpoint(checkpointId) { + let checkpointIndex = null; + const patchesSinceCheckpoint = []; + + for (let i = this.undoStack.length - 1; i >= 0; i--) { + const entry = this.undoStack[i]; + if (checkpointIndex != null) { break; } + + switch (entry.constructor) { + case Checkpoint: + if (entry.id === checkpointId) { + checkpointIndex = i; + } + break; + case Transaction: + patchesSinceCheckpoint.unshift(entry.patch); + break; + case Patch: + patchesSinceCheckpoint.unshift(entry); + break; + default: + throw new Error(`Unexpected undo stack entry type: ${entry.constructor.name}`); + } + } + + if (checkpointIndex != null) { + return Patch.compose(patchesSinceCheckpoint).getChanges(); + } else { + return null; + } + } + + groupLastChanges() { + let markerSnapshotAfter = null; + let markerSnapshotBefore = null; + const patchesSinceCheckpoint = []; + + for (let i = this.undoStack.length - 1; i >= 0; i--) { + const entry = this.undoStack[i]; + switch (entry.constructor) { + case Checkpoint: + if (entry.isBarrier) { return false; } + break; + case Transaction: + if (patchesSinceCheckpoint.length === 0) { + ({ + markerSnapshotAfter + } = entry); + } else if (patchesSinceCheckpoint.length === 1) { + ({ + markerSnapshotBefore + } = entry); + } + patchesSinceCheckpoint.unshift(entry.patch); + break; + case Patch: + patchesSinceCheckpoint.unshift(entry); + break; + default: + throw new Error(`Unexpected undo stack entry type: ${entry.constructor.name}`); + } + + if (patchesSinceCheckpoint.length === 2) { + const composedPatch = Patch.compose(patchesSinceCheckpoint); + this.undoStack.splice(i); + this.undoStack.push(new Transaction(markerSnapshotBefore, composedPatch, markerSnapshotAfter)); + return true; + } + } + } + + enforceUndoStackSizeLimit() { + if (this.undoStack.length > this.maxUndoEntries) { + this.undoStack.splice(0, this.undoStack.length - this.maxUndoEntries); + } + } + + applyGroupingInterval(groupingInterval) { + const topEntry = this.undoStack[this.undoStack.length - 1]; + const previousEntry = this.undoStack[this.undoStack.length - 2]; + + if (topEntry instanceof Transaction) { + topEntry.groupingInterval = groupingInterval; + } else { + return; + } + + if (groupingInterval === 0) { return; } + + if (previousEntry instanceof Transaction && topEntry.shouldGroupWith(previousEntry)) { + this.undoStack.splice(this.undoStack.length - 2, 2, topEntry.groupWith(previousEntry)); + } + } + + pushChange({newStart, oldExtent, newExtent, oldText, newText}) { + const patch = new Patch; + patch.splice(newStart, oldExtent, newExtent, oldText, newText); + this.pushPatch(patch); + } + + pushPatch(patch) { + this.undoStack.push(patch); + this.clearRedoStack(); + } + + undo() { + let snapshotBelow = null; + let patch = null; + let spliceIndex = null; + + for (let i = this.undoStack.length - 1; i >= 0; i--) { + const entry = this.undoStack[i]; + if (spliceIndex != null) { break; } + + switch (entry.constructor) { + case Checkpoint: + if (entry.isBarrier) { + return false; + } + break; + case Transaction: + snapshotBelow = entry.markerSnapshotBefore; + patch = entry.patch.invert(); + spliceIndex = i; + break; + case Patch: + patch = entry.invert(); + spliceIndex = i; + break; + default: + throw new Error(`Unexpected entry type when popping undoStack: ${entry.constructor.name}`); + } + } + + if (spliceIndex != null) { + // This feels strange, but what it actually does is remove N elements + // from the undo stack in place (as is expected)… and then the `reverse` + // is applied to the items that were removed from the stack. That's + // appropriate, since they're suppopsed to flip their order when going + // onto the redo stack. + this.redoStack.push(...this.undoStack.splice(spliceIndex).reverse() || []); + return { + textUpdates: patch.getChanges(), + markers: snapshotBelow + }; + } else { + return false; + } + } + + redo() { + let snapshotBelow = null; + let patch = null; + let spliceIndex = null; + + for (let i = this.redoStack.length - 1; i >= 0; i--) { + const entry = this.redoStack[i]; + if (spliceIndex != null) { break; } + + switch (entry.constructor) { + case Checkpoint: + if (entry.isBarrier) { + throw new Error("Invalid redo stack state"); + } + break; + case Transaction: + snapshotBelow = entry.markerSnapshotAfter; + ({ + patch + } = entry); + spliceIndex = i; + break; + case Patch: + patch = entry; + spliceIndex = i; + break; + default: + throw new Error(`Unexpected entry type when popping redoStack: ${entry.constructor.name}`); + } + } + + while (this.redoStack[spliceIndex - 1] instanceof Checkpoint) { + spliceIndex--; + } + + if (spliceIndex != null) { + // This feels strange, but what it actually does is remove N elements + // from the redo stack in place (as is expected)… and then the `reverse` + // is applied to the items that were removed from the stack. That's + // appropriate, since they're suppopsed to flip their order when going + // onto the undo stack. + this.undoStack.push(...this.redoStack.splice(spliceIndex).reverse() || []); + return { + textUpdates: patch.getChanges(), + markers: snapshotBelow + }; + } else { + return false; + } + } + + revertToCheckpoint(checkpointId) { + let snapshotBelow = null; + let spliceIndex = null; + const patchesSinceCheckpoint = []; + + for (let i = this.undoStack.length - 1; i >= 0; i--) { + const entry = this.undoStack[i]; + if (spliceIndex != null) { break; } + + switch (entry.constructor) { + case Checkpoint: + if (entry.id === checkpointId) { + snapshotBelow = entry.snapshot; + spliceIndex = i; + } else if (entry.isBarrier) { + return false; + } + break; + case Transaction: + patchesSinceCheckpoint.push(entry.patch.invert()); + break; + default: + patchesSinceCheckpoint.push(entry.invert()); + } + } + + if (spliceIndex != null) { + this.undoStack.splice(spliceIndex); + return { + textUpdates: Patch.compose(patchesSinceCheckpoint).getChanges(), + markers: snapshotBelow + }; + } else { + return false; + } + } + + clear() { + this.clearUndoStack(); + this.clearRedoStack(); + } + + clearUndoStack() { + this.undoStack.length = 0; + } + + clearRedoStack() { + this.redoStack.length = 0; + } + + toString() { + let output = ''; + for (let entry of this.undoStack) { + switch (entry.constructor) { + case Checkpoint: + output += "Checkpoint, "; + break; + case Transaction: + output += "Transaction, "; + break; + case Patch: + output += "Patch, "; + break; + default: + output += `Unknown {${JSON.stringify(entry)}}, `; + } + } + return '[' + output.slice(0, -2) + ']'; + } + + serialize(options) { + return { + version: SerializationVersion, + nextCheckpointId: this.nextCheckpointId, + undoStack: this.serializeStack(this.undoStack, options), + redoStack: this.serializeStack(this.redoStack, options), + maxUndoEntries: this.maxUndoEntries + }; + } + + deserialize(state) { + if (state.version !== SerializationVersion) { return; } + this.nextCheckpointId = state.nextCheckpointId; + this.maxUndoEntries = state.maxUndoEntries; + this.undoStack = this.deserializeStack(state.undoStack); + this.redoStack = this.deserializeStack(state.redoStack); + } + + getSnapshot(maxEntries) { + let entry; + const undoStackPatches = []; + const undoStack = []; + for (let i = this.undoStack.length - 1; i >= 0; i--) { + entry = this.undoStack[i]; + switch (entry.constructor) { + case Checkpoint: + undoStack.unshift(snapshotFromCheckpoint(entry)); + break; + case Transaction: + undoStack.unshift(snapshotFromTransaction(entry)); + undoStackPatches.unshift(entry.patch); + break; + } + + if (undoStack.length === maxEntries) { break; } + } + + const redoStack = []; + for (let j = this.redoStack.length - 1; j >= 0; j--) { + entry = this.redoStack[j]; + switch (entry.constructor) { + case Checkpoint: + redoStack.unshift(snapshotFromCheckpoint(entry)); + break; + case Transaction: + redoStack.unshift(snapshotFromTransaction(entry)); + break; + } + + if (redoStack.length === maxEntries) { break; } + } + + return { + nextCheckpointId: this.nextCheckpointId, + undoStackChanges: Patch.compose(undoStackPatches).getChanges(), + undoStack, + redoStack + }; + } + + restoreFromSnapshot({nextCheckpointId, undoStack, redoStack}) { + this.nextCheckpointId = nextCheckpointId; + this.undoStack = undoStack.map(function(entry) { + switch (entry.type) { + case 'transaction': + return transactionFromSnapshot(entry); + case 'checkpoint': + return checkpointFromSnapshot(entry); + } + }); + + return this.redoStack = redoStack.map(function(entry) { + switch (entry.type) { + case 'transaction': + return transactionFromSnapshot(entry); + case 'checkpoint': + return checkpointFromSnapshot(entry); + } + }); + } + + /* + Section: Private + */ + + getCheckpointIndex(checkpointId) { + for (let i = this.undoStack.length - 1; i >= 0; i--) { + const entry = this.undoStack[i]; + if (entry instanceof Checkpoint && (entry.id === checkpointId)) { + return i; + } + } + return null; + } + + serializeStack(stack, options) { + const result = []; + for (let entry of stack) { + switch (entry.constructor) { + case Checkpoint: + result.push({ + type: 'checkpoint', + id: entry.id, + snapshot: this.serializeSnapshot(entry.snapshot, options), + isBarrier: entry.isBarrier + }); + break; + case Transaction: + result.push({ + type: 'transaction', + markerSnapshotBefore: this.serializeSnapshot(entry.markerSnapshotBefore, options), + markerSnapshotAfter: this.serializeSnapshot(entry.markerSnapshotAfter, options), + patch: entry.patch.serialize().toString('base64') + }); + break; + case Patch: + result.push({ + type: 'patch', + data: entry.serialize().toString('base64') + }); + break; + default: + throw new Error(`Unexpected undoStack entry type during serialization: ${entry.constructor.name}`); + } + } + return result; + } + + deserializeStack(stack) { + let result = []; + for (let entry of stack) { + switch (entry.type) { + case 'checkpoint': + result.push(new Checkpoint( + entry.id, + MarkerLayer.deserializeSnapshot(entry.snapshot), + entry.isBarrier + )); + break; + case 'transaction': + result.push(new Transaction( + MarkerLayer.deserializeSnapshot(entry.markerSnapshotBefore), + Patch.deserialize(Buffer.from(entry.patch, 'base64')), + MarkerLayer.deserializeSnapshot(entry.markerSnapshotAfter) + )); + break; + case 'patch': + result.push(Patch.deserialize(Buffer.from(entry.data, 'base64'))); + break; + default: + throw new Error(`Unexpected undoStack entry type during deserialization: ${entry.type}`); + } + } + return result; + } + + serializeSnapshot(snapshot, options) { + if (!options.markerLayers) { return; } + + const serializedLayerSnapshots = {}; + for (let layerId in snapshot) { + const layerSnapshot = snapshot[layerId]; + if (!this.buffer.getMarkerLayer(layerId)?.persistent) { continue; } + const serializedMarkerSnapshots = {}; + for (let markerId in layerSnapshot) { + const markerSnapshot = layerSnapshot[markerId]; + const serializedMarkerSnapshot = Object.assign({}, markerSnapshot); + delete serializedMarkerSnapshot.marker; + serializedMarkerSnapshots[markerId] = serializedMarkerSnapshot; + } + serializedLayerSnapshots[layerId] = serializedMarkerSnapshots; + } + return serializedLayerSnapshots; + } +} + +function snapshotFromCheckpoint(checkpoint) { + return { + type: 'checkpoint', + id: checkpoint.id, + markers: checkpoint.snapshot + }; +} + +function checkpointFromSnapshot ({id, markers}) { + return new Checkpoint(id, markers, false); +} + +function snapshotFromTransaction (transaction) { + const changes = []; + const iterable = transaction.patch.getChanges(); + for (let i = 0; i < iterable.length; i++) { + const change = iterable[i]; + changes.push({ + oldStart: change.oldStart, + oldEnd: change.oldEnd, + newStart: change.newStart, + newEnd: change.newEnd, + oldText: change.oldText, + newText: change.newText + }); + } + + return { + type: 'transaction', + changes, + markersBefore: transaction.markerSnapshotBefore, + markersAfter: transaction.markerSnapshotAfter + }; +} + +function transactionFromSnapshot ({changes, markersBefore, markersAfter}) { + // TODO: Return raw patch if there's no markersBefore && markersAfter + return new Transaction(markersBefore, patchFromChanges(changes), markersAfter); +} + +module.exports = DefaultHistoryProvider; diff --git a/src/display-layer.js b/src/display-layer.js index 87bc774f69..705002185e 100644 --- a/src/display-layer.js +++ b/src/display-layer.js @@ -1,4 +1,4 @@ -const {Patch} = require('superstring') +const {Patch} = require('@pulsar-edit/superstring') const {Emitter} = require('event-kit') const Point = require('./point') const Range = require('./range') @@ -9,7 +9,6 @@ const ScreenLineBuilder = require('./screen-line-builder') const {spliceArray} = require('./helpers') const {MAX_BUILT_IN_SCOPE_ID} = require('./constants') -module.exports = class DisplayLayer { constructor (id, buffer, params = {}) { this.id = id @@ -54,7 +53,14 @@ class DisplayLayer { this.rightmostScreenPosition = params.rightmostScreenPosition this.indexedBufferRowCount = params.indexedBufferRowCount } else { - this.spatialIndex = new Patch({mergeAdjacentHunks: false}) + this.spatialIndex = new Patch({ + // The `mergeAdjacentHunks` option in `superstring` was renamed to + // `mergeAdjacentChanges` at a certain point. In order to remain + // compatible with the broadest possible range of `superstring` + // dependencies, we pass both options here. + mergeAdjacentHunks: false, + mergeAdjacentChanges: false + }) this.tabCounts = [] this.screenLineLengths = [] this.rightmostScreenPosition = Point(0, 0) @@ -1246,3 +1252,5 @@ const NullDeadline = { didTimeout: false, timeRemaining () { return Infinity } } + +module.exports = DisplayLayer diff --git a/src/display-marker-layer.coffee b/src/display-marker-layer.coffee deleted file mode 100644 index 056d83f4d0..0000000000 --- a/src/display-marker-layer.coffee +++ /dev/null @@ -1,402 +0,0 @@ -{Emitter, CompositeDisposable} = require 'event-kit' -DisplayMarker = require './display-marker' -Range = require './range' -Point = require './point' - -# Public: *Experimental:* A container for a related set of markers at the -# {DisplayLayer} level. Wraps an underlying {MarkerLayer} on the {TextBuffer}. -# -# This API is experimental and subject to change on any release. -module.exports = -class DisplayMarkerLayer - constructor: (@displayLayer, @bufferMarkerLayer, @ownsBufferMarkerLayer) -> - {@id} = @bufferMarkerLayer - @bufferMarkerLayer.displayMarkerLayers.add(this) - @markersById = {} - @destroyed = false - @emitter = new Emitter - @subscriptions = new CompositeDisposable - @markersWithDestroyListeners = new Set - @subscriptions.add(@bufferMarkerLayer.onDidUpdate(@emitDidUpdate.bind(this))) - - ### - Section: Lifecycle - ### - - # Essential: Destroy this layer. - destroy: -> - return if @destroyed - @destroyed = true - @clear() if @ownsBufferMarkerLayer - @subscriptions.dispose() - @bufferMarkerLayer.displayMarkerLayers.delete(this) - @bufferMarkerLayer.destroy() if @ownsBufferMarkerLayer - @displayLayer.didDestroyMarkerLayer(@id) - @emitter.emit('did-destroy') - @emitter.clear() - - # Public: Destroy all markers in this layer. - clear: -> - @bufferMarkerLayer.clear() - - didClearBufferMarkerLayer: -> - @markersWithDestroyListeners.forEach (marker) -> marker.didDestroyBufferMarker() - @markersById = {} - - # Essential: Determine whether this layer has been destroyed. - # - # Returns a {Boolean}. - isDestroyed: -> - @destroyed - - ### - Section: Event Subscription - ### - - # Public: Subscribe to be notified synchronously when this layer is destroyed. - # - # Returns a {Disposable}. - onDidDestroy: (callback) -> - @emitter.on('did-destroy', callback) - - # Public: Subscribe to be notified asynchronously whenever markers are - # created, updated, or destroyed on this layer. *Prefer this method for - # optimal performance when interacting with layers that could contain large - # numbers of markers.* - # - # * `callback` A {Function} that will be called with no arguments when changes - # occur on this layer. - # - # Subscribers are notified once, asynchronously when any number of changes - # occur in a given tick of the event loop. You should re-query the layer - # to determine the state of markers in which you're interested in. It may - # be counter-intuitive, but this is much more efficient than subscribing to - # events on individual markers, which are expensive to deliver. - # - # Returns a {Disposable}. - onDidUpdate: (callback) -> - @emitter.on('did-update', callback) - - # Public: Subscribe to be notified synchronously whenever markers are created - # on this layer. *Avoid this method for optimal performance when interacting - # with layers that could contain large numbers of markers.* - # - # * `callback` A {Function} that will be called with a {TextEditorMarker} - # whenever a new marker is created. - # - # You should prefer {::onDidUpdate} when synchronous notifications aren't - # absolutely necessary. - # - # Returns a {Disposable}. - onDidCreateMarker: (callback) -> - @bufferMarkerLayer.onDidCreateMarker (bufferMarker) => - callback(@getMarker(bufferMarker.id)) - - ### - Section: Marker creation - ### - - # Public: Create a marker with the given screen range. - # - # * `range` A {Range} or range-compatible {Array} - # * `options` 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` {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. - # * `clipDirection` {String} If `'backward'`, returns the first valid - # position preceding an invalid position. If `'forward'`, returns the - # first valid position following an invalid position. If `'closest'`, - # returns the first valid position closest to an invalid position. - # Defaults to `'closest'`. Applies to the start and end of the given range. - # - # Returns a {DisplayMarker}. - markScreenRange: (screenRange, options) -> - screenRange = Range.fromObject(screenRange) - bufferRange = @displayLayer.translateScreenRange(screenRange, options) - @getMarker(@bufferMarkerLayer.markRange(bufferRange, options).id) - - # Public: Create a marker on this layer with its head at the given screen - # position and no tail. - # - # * `screenPosition` A {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` {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. - # * `clipDirection` {String} If `'backward'`, returns the first valid - # position preceding an invalid position. If `'forward'`, returns the - # first valid position following an invalid position. If `'closest'`, - # returns the first valid position closest to an invalid position. - # Defaults to `'closest'`. - # - # Returns a {DisplayMarker}. - markScreenPosition: (screenPosition, options) -> - screenPosition = Point.fromObject(screenPosition) - bufferPosition = @displayLayer.translateScreenPosition(screenPosition, options) - @getMarker(@bufferMarkerLayer.markPosition(bufferPosition, options).id) - - # Public: Create a marker with the given buffer range. - # - # * `range` A {Range} or range-compatible {Array} - # * `options` 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` {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 {DisplayMarker}. - markBufferRange: (bufferRange, options) -> - bufferRange = Range.fromObject(bufferRange) - @getMarker(@bufferMarkerLayer.markRange(bufferRange, options).id) - - # Public: Create a marker on this layer with its head at the given buffer - # position and no tail. - # - # * `bufferPosition` A {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` {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 {DisplayMarker}. - markBufferPosition: (bufferPosition, options) -> - @getMarker(@bufferMarkerLayer.markPosition(Point.fromObject(bufferPosition), options).id) - - ### - Section: Querying - ### - - # Essential: Get an existing marker by its id. - # - # Returns a {DisplayMarker}. - getMarker: (id) -> - if displayMarker = @markersById[id] - displayMarker - else if bufferMarker = @bufferMarkerLayer.getMarker(id) - @markersById[id] = new DisplayMarker(this, bufferMarker) - - # Essential: Get all markers in the layer. - # - # Returns an {Array} of {DisplayMarker}s. - getMarkers: -> - @bufferMarkerLayer.getMarkers().map ({id}) => @getMarker(id) - - # Public: Get the number of markers in the marker layer. - # - # Returns a {Number}. - getMarkerCount: -> - @bufferMarkerLayer.getMarkerCount() - - # Public: Find markers in the layer conforming to the given parameters. - # - # This method finds markers based on the given properties. Markers can be - # associated with custom properties that will be compared with basic equality. - # In addition, there are several special properties that will be compared - # with the range of the markers rather than their properties. - # - # * `properties` An {Object} containing properties that each returned marker - # must satisfy. Markers can be associated with custom properties, which are - # compared with basic equality. In addition, several reserved properties - # can be used to filter markers based on their current range: - # * `startBufferPosition` Only include markers starting at this {Point} in buffer coordinates. - # * `endBufferPosition` Only include markers ending at this {Point} in buffer coordinates. - # * `startScreenPosition` Only include markers starting at this {Point} in screen coordinates. - # * `endScreenPosition` Only include markers ending at this {Point} in screen coordinates. - # * `startsInBufferRange` Only include markers starting inside this {Range} in buffer coordinates. - # * `endsInBufferRange` Only include markers ending inside this {Range} in buffer coordinates. - # * `startsInScreenRange` Only include markers starting inside this {Range} in screen coordinates. - # * `endsInScreenRange` Only include markers ending inside this {Range} in screen coordinates. - # * `startBufferRow` Only include markers starting at this row in buffer coordinates. - # * `endBufferRow` Only include markers ending at this row in buffer coordinates. - # * `startScreenRow` Only include markers starting at this row in screen coordinates. - # * `endScreenRow` Only include markers ending at this row in screen coordinates. - # * `intersectsBufferRowRange` Only include markers intersecting this {Array} - # of `[startRow, endRow]` in buffer coordinates. - # * `intersectsScreenRowRange` Only include markers intersecting this {Array} - # of `[startRow, endRow]` in screen coordinates. - # * `containsBufferRange` Only include markers containing this {Range} in buffer coordinates. - # * `containsBufferPosition` Only include markers containing this {Point} in buffer coordinates. - # * `containedInBufferRange` Only include markers contained in this {Range} in buffer coordinates. - # * `containedInScreenRange` Only include markers contained in this {Range} in screen coordinates. - # * `intersectsBufferRange` Only include markers intersecting this {Range} in buffer coordinates. - # * `intersectsScreenRange` Only include markers intersecting this {Range} in screen coordinates. - # - # Returns an {Array} of {DisplayMarker}s - findMarkers: (params) -> - params = @translateToBufferMarkerLayerFindParams(params) - @bufferMarkerLayer.findMarkers(params).map (stringMarker) => @getMarker(stringMarker.id) - - ### - Section: Private - ### - - translateBufferPosition: (bufferPosition, options) -> - @displayLayer.translateBufferPosition(bufferPosition, options) - - translateBufferRange: (bufferRange, options) -> - @displayLayer.translateBufferRange(bufferRange, options) - - translateScreenPosition: (screenPosition, options) -> - @displayLayer.translateScreenPosition(screenPosition, options) - - translateScreenRange: (screenRange, options) -> - @displayLayer.translateScreenRange(screenRange, options) - - emitDidUpdate: -> - @emitter.emit('did-update') - - notifyObserversIfMarkerScreenPositionsChanged: -> - for marker in @getMarkers() - marker.notifyObservers(false) - return - - destroyMarker: (id) -> - if marker = @markersById[id] - marker.didDestroyBufferMarker() - - didDestroyMarker: (marker) -> - @markersWithDestroyListeners.delete(marker) - delete @markersById[marker.id] - - translateToBufferMarkerLayerFindParams: (params) -> - bufferMarkerLayerFindParams = {} - for key, value of params - switch key - when 'startBufferPosition' - key = 'startPosition' - when 'endBufferPosition' - key = 'endPosition' - when 'startScreenPosition' - key = 'startPosition' - value = @displayLayer.translateScreenPosition(value) - when 'endScreenPosition' - key = 'endPosition' - value = @displayLayer.translateScreenPosition(value) - when 'startsInBufferRange' - key = 'startsInRange' - when 'endsInBufferRange' - key = 'endsInRange' - when 'startsInScreenRange' - key = 'startsInRange' - value = @displayLayer.translateScreenRange(value) - when 'endsInScreenRange' - key = 'endsInRange' - value = @displayLayer.translateScreenRange(value) - when 'startBufferRow' - key = 'startRow' - when 'endBufferRow' - key = 'endRow' - when 'startScreenRow' - key = 'startsInRange' - startBufferPosition = @displayLayer.translateScreenPosition(Point(value, 0)) - endBufferPosition = @displayLayer.translateScreenPosition(Point(value, Infinity)) - value = Range(startBufferPosition, endBufferPosition) - when 'endScreenRow' - key = 'endsInRange' - startBufferPosition = @displayLayer.translateScreenPosition(Point(value, 0)) - endBufferPosition = @displayLayer.translateScreenPosition(Point(value, Infinity)) - value = Range(startBufferPosition, endBufferPosition) - when 'intersectsBufferRowRange' - key = 'intersectsRowRange' - when 'intersectsScreenRowRange' - key = 'intersectsRange' - [startScreenRow, endScreenRow] = value - startBufferPosition = @displayLayer.translateScreenPosition(Point(startScreenRow, 0)) - endBufferPosition = @displayLayer.translateScreenPosition(Point(endScreenRow, Infinity)) - value = Range(startBufferPosition, endBufferPosition) - when 'containsBufferRange' - key = 'containsRange' - when 'containsScreenRange' - key = 'containsRange' - value = @displayLayer.translateScreenRange(value) - when 'containsBufferPosition' - key = 'containsPosition' - when 'containsScreenPosition' - key = 'containsPosition' - value = @displayLayer.translateScreenPosition(value) - when 'containedInBufferRange' - key = 'containedInRange' - when 'containedInScreenRange' - key = 'containedInRange' - value = @displayLayer.translateScreenRange(value) - when 'intersectsBufferRange' - key = 'intersectsRange' - when 'intersectsScreenRange' - key = 'intersectsRange' - value = @displayLayer.translateScreenRange(value) - bufferMarkerLayerFindParams[key] = value - - bufferMarkerLayerFindParams diff --git a/src/display-marker-layer.js b/src/display-marker-layer.js new file mode 100644 index 0000000000..f01c099bfe --- /dev/null +++ b/src/display-marker-layer.js @@ -0,0 +1,481 @@ +const {Emitter, CompositeDisposable} = require('event-kit'); +const DisplayMarker = require('./display-marker'); +const Range = require('./range'); +const Point = require('./point'); + +// Public: *Experimental:* A container for a related set of markers at the +// {DisplayLayer} level. Wraps an underlying {MarkerLayer} on the {TextBuffer}. +// +// This API is experimental and subject to change on any release. +class DisplayMarkerLayer { + constructor(displayLayer, bufferMarkerLayer, ownsBufferMarkerLayer) { + this.displayLayer = displayLayer; + this.bufferMarkerLayer = bufferMarkerLayer; + this.ownsBufferMarkerLayer = ownsBufferMarkerLayer; + this.id = this.bufferMarkerLayer.id; + this.bufferMarkerLayer.displayMarkerLayers.add(this); + this.markersById = {}; + this.destroyed = false; + this.emitter = new Emitter; + this.subscriptions = new CompositeDisposable; + this.markersWithDestroyListeners = new Set; + this.subscriptions.add( + this.bufferMarkerLayer.onDidUpdate(this.emitDidUpdate.bind(this)) + ); + } + + /* + Section: Lifecycle + */ + + // Essential: Destroy this layer. + destroy() { + if (this.destroyed) { return; } + this.destroyed = true; + + if (this.ownsBufferMarkerLayer) { this.clear(); } + this.subscriptions.dispose(); + this.bufferMarkerLayer.displayMarkerLayers.delete(this); + if (this.ownsBufferMarkerLayer) { this.bufferMarkerLayer.destroy(); } + this.displayLayer.didDestroyMarkerLayer(this.id); + + this.emitter.emit('did-destroy'); + this.emitter.clear(); + } + + // Public: Destroy all markers in this layer. + clear() { + this.bufferMarkerLayer.clear(); + } + + didClearBufferMarkerLayer() { + for (let marker of this.markersWithDestroyListeners) { + marker.didDestroyBufferMarker(); + } + this.markersById = {}; + } + + // Essential: Determine whether this layer has been destroyed. + // + // Returns a {Boolean}. + isDestroyed() { + return this.destroyed; + } + + /* + Section: Event Subscription + */ + + // Public: Subscribe to be notified synchronously when this layer is destroyed. + // + // Returns a {Disposable}. + onDidDestroy(callback) { + return this.emitter.on('did-destroy', callback); + } + + // Public: Subscribe to be notified asynchronously whenever markers are + // created, updated, or destroyed on this layer. *Prefer this method for + // optimal performance when interacting with layers that could contain large + // numbers of markers.* + // + // * `callback` A {Function} that will be called with no arguments when changes + // occur on this layer. + // + // Subscribers are notified once, asynchronously when any number of changes + // occur in a given tick of the event loop. You should re-query the layer + // to determine the state of markers in which you're interested in. It may + // be counter-intuitive, but this is much more efficient than subscribing to + // events on individual markers, which are expensive to deliver. + // + // Returns a {Disposable}. + onDidUpdate(callback) { + return this.emitter.on('did-update', callback); + } + + // Public: Subscribe to be notified synchronously whenever markers are created + // on this layer. *Avoid this method for optimal performance when interacting + // with layers that could contain large numbers of markers.* + // + // * `callback` A {Function} that will be called with a {TextEditorMarker} + // whenever a new marker is created. + // + // You should prefer {::onDidUpdate} when synchronous notifications aren't + // absolutely necessary. + // + // Returns a {Disposable}. + onDidCreateMarker(callback) { + return this.bufferMarkerLayer.onDidCreateMarker(bufferMarker => { + return callback(this.getMarker(bufferMarker.id)); + }); + } + + /* + Section: Marker creation + */ + + // Public: Create a marker with the given screen range. + // + // * `range` A {Range} or range-compatible {Array} + // * `options` 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` {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. + // * `clipDirection` {String} If `'backward'`, returns the first valid + // position preceding an invalid position. If `'forward'`, returns the + // first valid position following an invalid position. If `'closest'`, + // returns the first valid position closest to an invalid position. + // Defaults to `'closest'`. Applies to the start and end of the given range. + // + // Returns a {DisplayMarker}. + markScreenRange(screenRange, options) { + screenRange = Range.fromObject(screenRange); + const bufferRange = this.displayLayer.translateScreenRange(screenRange, options); + return this.getMarker(this.bufferMarkerLayer.markRange(bufferRange, options).id); + } + + // Public: Create a marker on this layer with its head at the given screen + // position and no tail. + // + // * `screenPosition` A {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` {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. + // * `clipDirection` {String} If `'backward'`, returns the first valid + // position preceding an invalid position. If `'forward'`, returns the + // first valid position following an invalid position. If `'closest'`, + // returns the first valid position closest to an invalid position. + // Defaults to `'closest'`. + // + // Returns a {DisplayMarker}. + markScreenPosition(screenPosition, options) { + screenPosition = Point.fromObject(screenPosition); + const bufferPosition = this.displayLayer.translateScreenPosition(screenPosition, options); + return this.getMarker( + this.bufferMarkerLayer.markPosition(bufferPosition, options).id + ); + } + + // Public: Create a marker with the given buffer range. + // + // * `range` A {Range} or range-compatible {Array} + // * `options` 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` {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 {DisplayMarker}. + markBufferRange(bufferRange, options) { + bufferRange = Range.fromObject(bufferRange); + return this.getMarker( + this.bufferMarkerLayer.markRange(bufferRange, options).id + ); + } + + // Public: Create a marker on this layer with its head at the given buffer + // position and no tail. + // + // * `bufferPosition` A {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` {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 {DisplayMarker}. + markBufferPosition(bufferPosition, options) { + return this.getMarker( + this.bufferMarkerLayer.markPosition( + Point.fromObject(bufferPosition), + options + ).id + ); + } + + /* + Section: Querying + */ + + // Essential: Get an existing marker by its id. + // + // Returns a {DisplayMarker}. + getMarker(id) { + let bufferMarker, displayMarker; + if (displayMarker = this.markersById[id]) { + return displayMarker; + } else if (bufferMarker = this.bufferMarkerLayer.getMarker(id)) { + return this.markersById[id] = new DisplayMarker(this, bufferMarker); + } + } + + // Essential: Get all markers in the layer. + // + // Returns an {Array} of {DisplayMarker}s. + getMarkers() { + return this.bufferMarkerLayer.getMarkers().map( + ({ id }) => this.getMarker(id) + ); + } + + // Public: Get the number of markers in the marker layer. + // + // Returns a {Number}. + getMarkerCount() { + return this.bufferMarkerLayer.getMarkerCount(); + } + + // Public: Find markers in the layer conforming to the given parameters. + // + // This method finds markers based on the given properties. Markers can be + // associated with custom properties that will be compared with basic equality. + // In addition, there are several special properties that will be compared + // with the range of the markers rather than their properties. + // + // * `properties` An {Object} containing properties that each returned marker + // must satisfy. Markers can be associated with custom properties, which are + // compared with basic equality. In addition, several reserved properties + // can be used to filter markers based on their current range: + // * `startBufferPosition` Only include markers starting at this {Point} in buffer coordinates. + // * `endBufferPosition` Only include markers ending at this {Point} in buffer coordinates. + // * `startScreenPosition` Only include markers starting at this {Point} in screen coordinates. + // * `endScreenPosition` Only include markers ending at this {Point} in screen coordinates. + // * `startsInBufferRange` Only include markers starting inside this {Range} in buffer coordinates. + // * `endsInBufferRange` Only include markers ending inside this {Range} in buffer coordinates. + // * `startsInScreenRange` Only include markers starting inside this {Range} in screen coordinates. + // * `endsInScreenRange` Only include markers ending inside this {Range} in screen coordinates. + // * `startBufferRow` Only include markers starting at this row in buffer coordinates. + // * `endBufferRow` Only include markers ending at this row in buffer coordinates. + // * `startScreenRow` Only include markers starting at this row in screen coordinates. + // * `endScreenRow` Only include markers ending at this row in screen coordinates. + // * `intersectsBufferRowRange` Only include markers intersecting this {Array} + // of `[startRow, endRow]` in buffer coordinates. + // * `intersectsScreenRowRange` Only include markers intersecting this {Array} + // of `[startRow, endRow]` in screen coordinates. + // * `containsBufferRange` Only include markers containing this {Range} in buffer coordinates. + // * `containsBufferPosition` Only include markers containing this {Point} in buffer coordinates. + // * `containedInBufferRange` Only include markers contained in this {Range} in buffer coordinates. + // * `containedInScreenRange` Only include markers contained in this {Range} in screen coordinates. + // * `intersectsBufferRange` Only include markers intersecting this {Range} in buffer coordinates. + // * `intersectsScreenRange` Only include markers intersecting this {Range} in screen coordinates. + // + // Returns an {Array} of {DisplayMarker}s + findMarkers(params) { + params = this.translateToBufferMarkerLayerFindParams(params); + return this.bufferMarkerLayer.findMarkers(params).map( + stringMarker => this.getMarker(stringMarker.id) + ); + } + + /* + Section: Private + */ + + translateBufferPosition(bufferPosition, options) { + return this.displayLayer.translateBufferPosition(bufferPosition, options); + } + + translateBufferRange(bufferRange, options) { + return this.displayLayer.translateBufferRange(bufferRange, options); + } + + translateScreenPosition(screenPosition, options) { + return this.displayLayer.translateScreenPosition(screenPosition, options); + } + + translateScreenRange(screenRange, options) { + return this.displayLayer.translateScreenRange(screenRange, options); + } + + emitDidUpdate() { + return this.emitter.emit('did-update'); + } + + notifyObserversIfMarkerScreenPositionsChanged() { + for (let marker of this.getMarkers()) { + marker.notifyObservers(false); + } + } + + destroyMarker(id) { + let marker; + if (marker = this.markersById[id]) { + return marker.didDestroyBufferMarker(); + } + } + + didDestroyMarker(marker) { + this.markersWithDestroyListeners.delete(marker); + return delete this.markersById[marker.id]; + } + + translateToBufferMarkerLayerFindParams(params) { + const bufferMarkerLayerFindParams = {}; + for (let key in params) { + let value = params[key]; + switch (key) { + case 'startBufferPosition': + key = 'startPosition'; + break; + case 'endBufferPosition': + key = 'endPosition'; + break; + case 'startScreenPosition': + key = 'startPosition'; + value = this.displayLayer.translateScreenPosition(value); + break; + case 'endScreenPosition': + key = 'endPosition'; + value = this.displayLayer.translateScreenPosition(value); + break; + case 'startsInBufferRange': + key = 'startsInRange'; + break; + case 'endsInBufferRange': + key = 'endsInRange'; + break; + case 'startsInScreenRange': + key = 'startsInRange'; + value = this.displayLayer.translateScreenRange(value); + break; + case 'endsInScreenRange': + key = 'endsInRange'; + value = this.displayLayer.translateScreenRange(value); + break; + case 'startBufferRow': + key = 'startRow'; + break; + case 'endBufferRow': + key = 'endRow'; + break; + case 'startScreenRow': + key = 'startsInRange'; + var startBufferPosition = this.displayLayer.translateScreenPosition(Point(value, 0)); + var endBufferPosition = this.displayLayer.translateScreenPosition(Point(value, Infinity)); + value = Range(startBufferPosition, endBufferPosition); + break; + case 'endScreenRow': + key = 'endsInRange'; + startBufferPosition = this.displayLayer.translateScreenPosition(Point(value, 0)); + endBufferPosition = this.displayLayer.translateScreenPosition(Point(value, Infinity)); + value = Range(startBufferPosition, endBufferPosition); + break; + case 'intersectsBufferRowRange': + key = 'intersectsRowRange'; + break; + case 'intersectsScreenRowRange': + key = 'intersectsRange'; + var [startScreenRow, endScreenRow] = Array.from(value); + startBufferPosition = this.displayLayer.translateScreenPosition(Point(startScreenRow, 0)); + endBufferPosition = this.displayLayer.translateScreenPosition(Point(endScreenRow, Infinity)); + value = Range(startBufferPosition, endBufferPosition); + break; + case 'containsBufferRange': + key = 'containsRange'; + break; + case 'containsScreenRange': + key = 'containsRange'; + value = this.displayLayer.translateScreenRange(value); + break; + case 'containsBufferPosition': + key = 'containsPosition'; + break; + case 'containsScreenPosition': + key = 'containsPosition'; + value = this.displayLayer.translateScreenPosition(value); + break; + case 'containedInBufferRange': + key = 'containedInRange'; + break; + case 'containedInScreenRange': + key = 'containedInRange'; + value = this.displayLayer.translateScreenRange(value); + break; + case 'intersectsBufferRange': + key = 'intersectsRange'; + break; + case 'intersectsScreenRange': + key = 'intersectsRange'; + value = this.displayLayer.translateScreenRange(value); + break; + } + bufferMarkerLayerFindParams[key] = value; + } + + return bufferMarkerLayerFindParams; + } +} + +module.exports = DisplayMarkerLayer; diff --git a/src/display-marker.coffee b/src/display-marker.coffee deleted file mode 100644 index d8c92a2a02..0000000000 --- a/src/display-marker.coffee +++ /dev/null @@ -1,414 +0,0 @@ -{Emitter} = require 'event-kit' - -# Essential: Represents a buffer annotation that remains logically stationary -# even as the buffer changes. This is used to represent cursors, folds, snippet -# targets, misspelled words, and anything else that needs to track a logical -# location in the buffer over time. -# -# ### DisplayMarker Creation -# -# Use {DisplayMarkerLayer::markBufferRange} or {DisplayMarkerLayer::markScreenRange} -# rather than creating Markers directly. -# -# ### Head and Tail -# -# Markers always have a *head* and sometimes have a *tail*. If you think of a -# marker as an editor selection, the tail is the part that's stationary and the -# head is the part that moves when the mouse is moved. A marker without a tail -# always reports an empty range at the head position. A marker with a head position -# greater than the tail is in a "normal" orientation. If the head precedes the -# tail the marker is in a "reversed" orientation. -# -# ### Validity -# -# Markers are considered *valid* when they are first created. Depending on the -# invalidation strategy you choose, certain changes to the buffer can cause a -# marker to become invalid, for example if the text surrounding the marker is -# deleted. The strategies, in order of descending 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. -# -# See {TextBuffer::markRange} for usage. -module.exports = -class DisplayMarker - ### - Section: Construction and Destruction - ### - - constructor: (@layer, @bufferMarker) -> - {@id} = @bufferMarker - @hasChangeObservers = false - @emitter = new Emitter - @bufferMarkerSubscription = null - - # Essential: Destroys the marker, causing it to emit the 'destroyed' event. Once - # destroyed, a marker cannot be restored by undo/redo operations. - destroy: -> - unless @isDestroyed() - @bufferMarker.destroy() - - didDestroyBufferMarker: -> - @emitter.emit('did-destroy') - @layer.didDestroyMarker(this) - @emitter.dispose() - @emitter.clear() - @bufferMarkerSubscription?.dispose() - - # Essential: Creates and returns a new {DisplayMarker} with the same properties as - # this marker. - # - # {Selection} markers (markers with a custom property `type: "selection"`) - # should be copied with a different `type` value, for example with - # `marker.copy({type: null})`. Otherwise, the new marker's selection will - # be merged with this marker's selection, and a `null` value will be - # returned. - # - # * `properties` (optional) {Object} properties to associate with the new - # marker. The new marker's properties are computed by extending this marker's - # properties with `properties`. - # - # Returns a {DisplayMarker}. - copy: (params) -> - @layer.getMarker(@bufferMarker.copy(params).id) - - ### - Section: Event Subscription - ### - - # Essential: Invoke the given callback when the state of the marker changes. - # - # * `callback` {Function} to be called when the marker changes. - # * `event` {Object} with the following keys: - # * `oldHeadBufferPosition` {Point} representing the former head buffer position - # * `newHeadBufferPosition` {Point} representing the new head buffer position - # * `oldTailBufferPosition` {Point} representing the former tail buffer position - # * `newTailBufferPosition` {Point} representing the new tail buffer position - # * `oldHeadScreenPosition` {Point} representing the former head screen position - # * `newHeadScreenPosition` {Point} representing the new head screen position - # * `oldTailScreenPosition` {Point} representing the former tail screen position - # * `newTailScreenPosition` {Point} representing the new tail screen position - # * `wasValid` {Boolean} indicating whether the marker was valid before the change - # * `isValid` {Boolean} indicating whether the marker is now valid - # * `hadTail` {Boolean} indicating whether the marker had a tail before the change - # * `hasTail` {Boolean} indicating whether the marker now has a tail - # * `oldProperties` {Object} containing the marker's custom properties before the change. - # * `newProperties` {Object} containing the marker's custom properties after the change. - # * `textChanged` {Boolean} indicating whether this change was caused by a textual change - # to the buffer or whether the marker was manipulated directly via its public API. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidChange: (callback) -> - unless @hasChangeObservers - @oldHeadBufferPosition = @getHeadBufferPosition() - @oldHeadScreenPosition = @getHeadScreenPosition() - @oldTailBufferPosition = @getTailBufferPosition() - @oldTailScreenPosition = @getTailScreenPosition() - @wasValid = @isValid() - @bufferMarkerSubscription = @bufferMarker.onDidChange (event) => @notifyObservers(event.textChanged) - @hasChangeObservers = true - @emitter.on 'did-change', callback - - # Essential: Invoke the given callback when the marker is destroyed. - # - # * `callback` {Function} to be called when the marker is destroyed. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidDestroy: (callback) -> - @layer.markersWithDestroyListeners.add(this) - @emitter.on('did-destroy', callback) - - ### - Section: TextEditorMarker Details - ### - - # Essential: Returns a {Boolean} indicating whether the marker is valid. - # Markers can be invalidated when a region surrounding them in the buffer is - # changed. - isValid: -> - @bufferMarker.isValid() - - # Essential: Returns a {Boolean} indicating whether the marker has been - # destroyed. A marker can be invalid without being destroyed, in which case - # undoing the invalidating operation would restore the marker. Once a marker - # is destroyed by calling {DisplayMarker::destroy}, no undo/redo operation - # can ever bring it back. - isDestroyed: -> - @layer.isDestroyed() or @bufferMarker.isDestroyed() - - # Essential: Returns a {Boolean} indicating whether the head precedes the tail. - isReversed: -> - @bufferMarker.isReversed() - - # Essential: Returns a {Boolean} indicating whether changes that occur exactly - # at the marker's head or tail cause it to move. - isExclusive: -> - @bufferMarker.isExclusive() - - # Essential: Get the invalidation strategy for this marker. - # - # Valid values include: `never`, `surround`, `overlap`, `inside`, and `touch`. - # - # Returns a {String}. - getInvalidationStrategy: -> - @bufferMarker.getInvalidationStrategy() - - # Essential: Returns an {Object} containing any custom properties associated with - # the marker. - getProperties: -> - @bufferMarker.getProperties() - - # Essential: Merges an {Object} containing new properties into the marker's - # existing properties. - # - # * `properties` {Object} - setProperties: (properties) -> - @bufferMarker.setProperties(properties) - - # Essential: Returns whether this marker matches the given parameters. The - # parameters are the same as {DisplayMarkerLayer::findMarkers}. - matchesProperties: (attributes) -> - attributes = @layer.translateToBufferMarkerParams(attributes) - @bufferMarker.matchesParams(attributes) - - ### - Section: Comparing to other markers - ### - - # Essential: Compares this marker to another based on their ranges. - # - # * `other` {DisplayMarker} - # - # Returns a {Number} - compare: (otherMarker) -> - @bufferMarker.compare(otherMarker.bufferMarker) - - # Essential: Returns a {Boolean} indicating whether this marker is equivalent to - # another marker, meaning they have the same range and options. - # - # * `other` {DisplayMarker} other marker - isEqual: (other) -> - return false unless other instanceof @constructor - @bufferMarker.isEqual(other.bufferMarker) - - ### - Section: Managing the marker's range - ### - - # Essential: Gets the buffer range of this marker. - # - # Returns a {Range}. - getBufferRange: -> - @bufferMarker.getRange() - - # Essential: Gets the screen range of this marker. - # - # Returns a {Range}. - getScreenRange: -> - @layer.translateBufferRange(@getBufferRange()) - - # Essential: Modifies the buffer range of this marker. - # - # * `bufferRange` The new {Range} to use - # * `properties` (optional) {Object} properties to associate with the marker. - # * `reversed` {Boolean} If true, the marker will to be in a reversed orientation. - setBufferRange: (bufferRange, properties) -> - @bufferMarker.setRange(bufferRange, properties) - - # Essential: Modifies the screen range of this marker. - # - # * `screenRange` The new {Range} to use - # * `options` (optional) An {Object} with the following keys: - # * `reversed` {Boolean} If true, the marker will to be in a reversed orientation. - # * `clipDirection` {String} If `'backward'`, returns the first valid - # position preceding an invalid position. If `'forward'`, returns the - # first valid position following an invalid position. If `'closest'`, - # returns the first valid position closest to an invalid position. - # Defaults to `'closest'`. Applies to the start and end of the given range. - setScreenRange: (screenRange, options) -> - @setBufferRange(@layer.translateScreenRange(screenRange, options), options) - - # Extended: Retrieves the buffer position of the marker's head. - # - # Returns a {Point}. - getHeadBufferPosition: -> - @bufferMarker.getHeadPosition() - - # Extended: Sets the buffer position of the marker's head. - # - # * `bufferPosition` The new {Point} to use - setHeadBufferPosition: (bufferPosition) -> - @bufferMarker.setHeadPosition(bufferPosition) - - # Extended: Retrieves the screen position of the marker's head. - # - # * `options` (optional) An {Object} with the following keys: - # * `clipDirection` {String} If `'backward'`, returns the first valid - # position preceding an invalid position. If `'forward'`, returns the - # first valid position following an invalid position. If `'closest'`, - # returns the first valid position closest to an invalid position. - # Defaults to `'closest'`. Applies to the start and end of the given range. - # - # Returns a {Point}. - getHeadScreenPosition: (options) -> - @layer.translateBufferPosition(@bufferMarker.getHeadPosition(), options) - - # Extended: Sets the screen position of the marker's head. - # - # * `screenPosition` The new {Point} to use - # * `options` (optional) An {Object} with the following keys: - # * `clipDirection` {String} If `'backward'`, returns the first valid - # position preceding an invalid position. If `'forward'`, returns the - # first valid position following an invalid position. If `'closest'`, - # returns the first valid position closest to an invalid position. - # Defaults to `'closest'`. Applies to the start and end of the given range. - setHeadScreenPosition: (screenPosition, options) -> - @setHeadBufferPosition(@layer.translateScreenPosition(screenPosition, options)) - - # Extended: Retrieves the buffer position of the marker's tail. - # - # Returns a {Point}. - getTailBufferPosition: -> - @bufferMarker.getTailPosition() - - # Extended: Sets the buffer position of the marker's tail. - # - # * `bufferPosition` The new {Point} to use - setTailBufferPosition: (bufferPosition) -> - @bufferMarker.setTailPosition(bufferPosition) - - # Extended: Retrieves the screen position of the marker's tail. - # - # * `options` (optional) An {Object} with the following keys: - # * `clipDirection` {String} If `'backward'`, returns the first valid - # position preceding an invalid position. If `'forward'`, returns the - # first valid position following an invalid position. If `'closest'`, - # returns the first valid position closest to an invalid position. - # Defaults to `'closest'`. Applies to the start and end of the given range. - # - # Returns a {Point}. - getTailScreenPosition: (options) -> - @layer.translateBufferPosition(@bufferMarker.getTailPosition(), options) - - # Extended: Sets the screen position of the marker's tail. - # - # * `screenPosition` The new {Point} to use - # * `options` (optional) An {Object} with the following keys: - # * `clipDirection` {String} If `'backward'`, returns the first valid - # position preceding an invalid position. If `'forward'`, returns the - # first valid position following an invalid position. If `'closest'`, - # returns the first valid position closest to an invalid position. - # Defaults to `'closest'`. Applies to the start and end of the given range. - setTailScreenPosition: (screenPosition, options) -> - @bufferMarker.setTailPosition(@layer.translateScreenPosition(screenPosition, options)) - - # Extended: Retrieves the buffer position of the marker's start. This will always be - # less than or equal to the result of {DisplayMarker::getEndBufferPosition}. - # - # Returns a {Point}. - getStartBufferPosition: -> - @bufferMarker.getStartPosition() - - # Essential: Retrieves the screen position of the marker's start. This will always be - # less than or equal to the result of {DisplayMarker::getEndScreenPosition}. - # - # * `options` (optional) An {Object} with the following keys: - # * `clipDirection` {String} If `'backward'`, returns the first valid - # position preceding an invalid position. If `'forward'`, returns the - # first valid position following an invalid position. If `'closest'`, - # returns the first valid position closest to an invalid position. - # Defaults to `'closest'`. Applies to the start and end of the given range. - # - # Returns a {Point}. - getStartScreenPosition: (options) -> - @layer.translateBufferPosition(@getStartBufferPosition(), options) - - # Extended: Retrieves the buffer position of the marker's end. This will always be - # greater than or equal to the result of {DisplayMarker::getStartBufferPosition}. - # - # Returns a {Point}. - getEndBufferPosition: -> - @bufferMarker.getEndPosition() - - # Essential: Retrieves the screen position of the marker's end. This will always be - # greater than or equal to the result of {DisplayMarker::getStartScreenPosition}. - # - # * `options` (optional) An {Object} with the following keys: - # * `clipDirection` {String} If `'backward'`, returns the first valid - # position preceding an invalid position. If `'forward'`, returns the - # first valid position following an invalid position. If `'closest'`, - # returns the first valid position closest to an invalid position. - # Defaults to `'closest'`. Applies to the start and end of the given range. - # - # Returns a {Point}. - getEndScreenPosition: (options) -> - @layer.translateBufferPosition(@getEndBufferPosition(), options) - - # Extended: Returns a {Boolean} indicating whether the marker has a tail. - hasTail: -> - @bufferMarker.hasTail() - - # Extended: Plants the marker's tail at the current head position. After calling - # the marker's tail position will be its head position at the time of the - # call, regardless of where the marker's head is moved. - plantTail: -> - @bufferMarker.plantTail() - - # Extended: Removes the marker's tail. After calling the marker's head position - # will be reported as its current tail position until the tail is planted - # again. - clearTail: -> - @bufferMarker.clearTail() - - toString: -> - "[Marker #{@id}, bufferRange: #{@getBufferRange()}, screenRange: #{@getScreenRange()}}]" - - ### - Section: Private - ### - - inspect: -> - @toString() - - notifyObservers: (textChanged) -> - return unless @hasChangeObservers - textChanged ?= false - - newHeadBufferPosition = @getHeadBufferPosition() - newHeadScreenPosition = @getHeadScreenPosition() - newTailBufferPosition = @getTailBufferPosition() - newTailScreenPosition = @getTailScreenPosition() - isValid = @isValid() - - return if isValid is @wasValid and - newHeadBufferPosition.isEqual(@oldHeadBufferPosition) and - newHeadScreenPosition.isEqual(@oldHeadScreenPosition) and - newTailBufferPosition.isEqual(@oldTailBufferPosition) and - newTailScreenPosition.isEqual(@oldTailScreenPosition) - - changeEvent = { - @oldHeadScreenPosition, newHeadScreenPosition, - @oldTailScreenPosition, newTailScreenPosition, - @oldHeadBufferPosition, newHeadBufferPosition, - @oldTailBufferPosition, newTailBufferPosition, - textChanged, - @wasValid, - isValid - } - - @oldHeadBufferPosition = newHeadBufferPosition - @oldHeadScreenPosition = newHeadScreenPosition - @oldTailBufferPosition = newTailBufferPosition - @oldTailScreenPosition = newTailScreenPosition - @wasValid = isValid - - @emitter.emit 'did-change', changeEvent diff --git a/src/display-marker.js b/src/display-marker.js new file mode 100644 index 0000000000..ecbd335a99 --- /dev/null +++ b/src/display-marker.js @@ -0,0 +1,458 @@ +const {Emitter} = require('event-kit'); + +// Essential: Represents a buffer annotation that remains logically stationary +// even as the buffer changes. This is used to represent cursors, folds, snippet +// targets, misspelled words, and anything else that needs to track a logical +// location in the buffer over time. +// +// ### DisplayMarker Creation +// +// Use {DisplayMarkerLayer::markBufferRange} or {DisplayMarkerLayer::markScreenRange} +// rather than creating Markers directly. +// +// ### Head and Tail +// +// Markers always have a *head* and sometimes have a *tail*. If you think of a +// marker as an editor selection, the tail is the part that's stationary and the +// head is the part that moves when the mouse is moved. A marker without a tail +// always reports an empty range at the head position. A marker with a head position +// greater than the tail is in a "normal" orientation. If the head precedes the +// tail the marker is in a "reversed" orientation. +// +// ### Validity +// +// Markers are considered *valid* when they are first created. Depending on the +// invalidation strategy you choose, certain changes to the buffer can cause a +// marker to become invalid, for example if the text surrounding the marker is +// deleted. The strategies, in order of descending 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. +// +// See {TextBuffer::markRange} for usage. +class DisplayMarker { + /* + Section: Construction and Destruction + */ + + constructor(layer, bufferMarker) { + this.layer = layer; + this.bufferMarker = bufferMarker; + ({id: this.id} = this.bufferMarker); + this.hasChangeObservers = false; + this.emitter = new Emitter; + this.bufferMarkerSubscription = null; + } + + // Essential: Destroys the marker, causing it to emit the 'destroyed' event. Once + // destroyed, a marker cannot be restored by undo/redo operations. + destroy() { + if (!this.isDestroyed()) { + this.bufferMarker.destroy(); + } + } + + didDestroyBufferMarker() { + this.emitter.emit('did-destroy'); + this.layer.didDestroyMarker(this); + this.emitter.dispose(); + this.emitter.clear(); + this.bufferMarkerSubscription?.dispose(); + } + + // Essential: Creates and returns a new {DisplayMarker} with the same properties as + // this marker. + // + // {Selection} markers (markers with a custom property `type: "selection"`) + // should be copied with a different `type` value, for example with + // `marker.copy({type: null})`. Otherwise, the new marker's selection will + // be merged with this marker's selection, and a `null` value will be + // returned. + // + // * `properties` (optional) {Object} properties to associate with the new + // marker. The new marker's properties are computed by extending this marker's + // properties with `properties`. + // + // Returns a {DisplayMarker}. + copy(params) { + return this.layer.getMarker(this.bufferMarker.copy(params).id); + } + + /* + Section: Event Subscription + */ + + // Essential: Invoke the given callback when the state of the marker changes. + // + // * `callback` {Function} to be called when the marker changes. + // * `event` {Object} with the following keys: + // * `oldHeadBufferPosition` {Point} representing the former head buffer position + // * `newHeadBufferPosition` {Point} representing the new head buffer position + // * `oldTailBufferPosition` {Point} representing the former tail buffer position + // * `newTailBufferPosition` {Point} representing the new tail buffer position + // * `oldHeadScreenPosition` {Point} representing the former head screen position + // * `newHeadScreenPosition` {Point} representing the new head screen position + // * `oldTailScreenPosition` {Point} representing the former tail screen position + // * `newTailScreenPosition` {Point} representing the new tail screen position + // * `wasValid` {Boolean} indicating whether the marker was valid before the change + // * `isValid` {Boolean} indicating whether the marker is now valid + // * `hadTail` {Boolean} indicating whether the marker had a tail before the change + // * `hasTail` {Boolean} indicating whether the marker now has a tail + // * `oldProperties` {Object} containing the marker's custom properties before the change. + // * `newProperties` {Object} containing the marker's custom properties after the change. + // * `textChanged` {Boolean} indicating whether this change was caused by a textual change + // to the buffer or whether the marker was manipulated directly via its public API. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidChange(callback) { + if (!this.hasChangeObservers) { + this.oldHeadBufferPosition = this.getHeadBufferPosition(); + this.oldHeadScreenPosition = this.getHeadScreenPosition(); + this.oldTailBufferPosition = this.getTailBufferPosition(); + this.oldTailScreenPosition = this.getTailScreenPosition(); + this.wasValid = this.isValid(); + this.bufferMarkerSubscription = this.bufferMarker.onDidChange(event => this.notifyObservers(event.textChanged)); + this.hasChangeObservers = true; + } + return this.emitter.on('did-change', callback); + } + + // Essential: Invoke the given callback when the marker is destroyed. + // + // * `callback` {Function} to be called when the marker is destroyed. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidDestroy(callback) { + this.layer.markersWithDestroyListeners.add(this); + return this.emitter.on('did-destroy', callback); + } + + /* + Section: TextEditorMarker Details + */ + + // Essential: Returns a {Boolean} indicating whether the marker is valid. + // Markers can be invalidated when a region surrounding them in the buffer is + // changed. + isValid() { + return this.bufferMarker.isValid(); + } + + // Essential: Returns a {Boolean} indicating whether the marker has been + // destroyed. A marker can be invalid without being destroyed, in which case + // undoing the invalidating operation would restore the marker. Once a marker + // is destroyed by calling {DisplayMarker::destroy}, no undo/redo operation + // can ever bring it back. + isDestroyed() { + return this.layer.isDestroyed() || this.bufferMarker.isDestroyed(); + } + + // Essential: Returns a {Boolean} indicating whether the head precedes the tail. + isReversed() { + return this.bufferMarker.isReversed(); + } + + // Essential: Returns a {Boolean} indicating whether changes that occur exactly + // at the marker's head or tail cause it to move. + isExclusive() { + return this.bufferMarker.isExclusive(); + } + + // Essential: Get the invalidation strategy for this marker. + // + // Valid values include: `never`, `surround`, `overlap`, `inside`, and `touch`. + // + // Returns a {String}. + getInvalidationStrategy() { + return this.bufferMarker.getInvalidationStrategy(); + } + + // Essential: Returns an {Object} containing any custom properties associated with + // the marker. + getProperties() { + return this.bufferMarker.getProperties(); + } + + // Essential: Merges an {Object} containing new properties into the marker's + // existing properties. + // + // * `properties` {Object} + setProperties(properties) { + return this.bufferMarker.setProperties(properties); + } + + // Essential: Returns whether this marker matches the given parameters. The + // parameters are the same as {DisplayMarkerLayer::findMarkers}. + matchesProperties(attributes) { + attributes = this.layer.translateToBufferMarkerParams(attributes); + return this.bufferMarker.matchesParams(attributes); + } + + /* + Section: Comparing to other markers + */ + + // Essential: Compares this marker to another based on their ranges. + // + // * `other` {DisplayMarker} + // + // Returns a {Number} + compare(otherMarker) { + return this.bufferMarker.compare(otherMarker.bufferMarker); + } + + // Essential: Returns a {Boolean} indicating whether this marker is equivalent to + // another marker, meaning they have the same range and options. + // + // * `other` {DisplayMarker} other marker + isEqual(other) { + if (!(other instanceof this.constructor)) { return false; } + return this.bufferMarker.isEqual(other.bufferMarker); + } + + /* + Section: Managing the marker's range + */ + + // Essential: Gets the buffer range of this marker. + // + // Returns a {Range}. + getBufferRange() { + return this.bufferMarker.getRange(); + } + + // Essential: Gets the screen range of this marker. + // + // Returns a {Range}. + getScreenRange() { + return this.layer.translateBufferRange(this.getBufferRange()); + } + + // Essential: Modifies the buffer range of this marker. + // + // * `bufferRange` The new {Range} to use + // * `properties` (optional) {Object} properties to associate with the marker. + // * `reversed` {Boolean} If true, the marker will to be in a reversed orientation. + setBufferRange(bufferRange, properties) { + return this.bufferMarker.setRange(bufferRange, properties); + } + + // Essential: Modifies the screen range of this marker. + // + // * `screenRange` The new {Range} to use + // * `options` (optional) An {Object} with the following keys: + // * `reversed` {Boolean} If true, the marker will to be in a reversed orientation. + // * `clipDirection` {String} If `'backward'`, returns the first valid + // position preceding an invalid position. If `'forward'`, returns the + // first valid position following an invalid position. If `'closest'`, + // returns the first valid position closest to an invalid position. + // Defaults to `'closest'`. Applies to the start and end of the given range. + setScreenRange(screenRange, options) { + return this.setBufferRange(this.layer.translateScreenRange(screenRange, options), options); + } + + // Extended: Retrieves the buffer position of the marker's head. + // + // Returns a {Point}. + getHeadBufferPosition() { + return this.bufferMarker.getHeadPosition(); + } + + // Extended: Sets the buffer position of the marker's head. + // + // * `bufferPosition` The new {Point} to use + setHeadBufferPosition(bufferPosition) { + return this.bufferMarker.setHeadPosition(bufferPosition); + } + + // Extended: Retrieves the screen position of the marker's head. + // + // * `options` (optional) An {Object} with the following keys: + // * `clipDirection` {String} If `'backward'`, returns the first valid + // position preceding an invalid position. If `'forward'`, returns the + // first valid position following an invalid position. If `'closest'`, + // returns the first valid position closest to an invalid position. + // Defaults to `'closest'`. Applies to the start and end of the given range. + // + // Returns a {Point}. + getHeadScreenPosition(options) { + return this.layer.translateBufferPosition(this.bufferMarker.getHeadPosition(), options); + } + + // Extended: Sets the screen position of the marker's head. + // + // * `screenPosition` The new {Point} to use + // * `options` (optional) An {Object} with the following keys: + // * `clipDirection` {String} If `'backward'`, returns the first valid + // position preceding an invalid position. If `'forward'`, returns the + // first valid position following an invalid position. If `'closest'`, + // returns the first valid position closest to an invalid position. + // Defaults to `'closest'`. Applies to the start and end of the given range. + setHeadScreenPosition(screenPosition, options) { + return this.setHeadBufferPosition(this.layer.translateScreenPosition(screenPosition, options)); + } + + // Extended: Retrieves the buffer position of the marker's tail. + // + // Returns a {Point}. + getTailBufferPosition() { + return this.bufferMarker.getTailPosition(); + } + + // Extended: Sets the buffer position of the marker's tail. + // + // * `bufferPosition` The new {Point} to use + setTailBufferPosition(bufferPosition) { + return this.bufferMarker.setTailPosition(bufferPosition); + } + + // Extended: Retrieves the screen position of the marker's tail. + // + // * `options` (optional) An {Object} with the following keys: + // * `clipDirection` {String} If `'backward'`, returns the first valid + // position preceding an invalid position. If `'forward'`, returns the + // first valid position following an invalid position. If `'closest'`, + // returns the first valid position closest to an invalid position. + // Defaults to `'closest'`. Applies to the start and end of the given range. + // + // Returns a {Point}. + getTailScreenPosition(options) { + return this.layer.translateBufferPosition(this.bufferMarker.getTailPosition(), options); + } + + // Extended: Sets the screen position of the marker's tail. + // + // * `screenPosition` The new {Point} to use + // * `options` (optional) An {Object} with the following keys: + // * `clipDirection` {String} If `'backward'`, returns the first valid + // position preceding an invalid position. If `'forward'`, returns the + // first valid position following an invalid position. If `'closest'`, + // returns the first valid position closest to an invalid position. + // Defaults to `'closest'`. Applies to the start and end of the given range. + setTailScreenPosition(screenPosition, options) { + return this.bufferMarker.setTailPosition(this.layer.translateScreenPosition(screenPosition, options)); + } + + // Extended: Retrieves the buffer position of the marker's start. This will always be + // less than or equal to the result of {DisplayMarker::getEndBufferPosition}. + // + // Returns a {Point}. + getStartBufferPosition() { + return this.bufferMarker.getStartPosition(); + } + + // Essential: Retrieves the screen position of the marker's start. This will always be + // less than or equal to the result of {DisplayMarker::getEndScreenPosition}. + // + // * `options` (optional) An {Object} with the following keys: + // * `clipDirection` {String} If `'backward'`, returns the first valid + // position preceding an invalid position. If `'forward'`, returns the + // first valid position following an invalid position. If `'closest'`, + // returns the first valid position closest to an invalid position. + // Defaults to `'closest'`. Applies to the start and end of the given range. + // + // Returns a {Point}. + getStartScreenPosition(options) { + return this.layer.translateBufferPosition(this.getStartBufferPosition(), options); + } + + // Extended: Retrieves the buffer position of the marker's end. This will always be + // greater than or equal to the result of {DisplayMarker::getStartBufferPosition}. + // + // Returns a {Point}. + getEndBufferPosition() { + return this.bufferMarker.getEndPosition(); + } + + // Essential: Retrieves the screen position of the marker's end. This will always be + // greater than or equal to the result of {DisplayMarker::getStartScreenPosition}. + // + // * `options` (optional) An {Object} with the following keys: + // * `clipDirection` {String} If `'backward'`, returns the first valid + // position preceding an invalid position. If `'forward'`, returns the + // first valid position following an invalid position. If `'closest'`, + // returns the first valid position closest to an invalid position. + // Defaults to `'closest'`. Applies to the start and end of the given range. + // + // Returns a {Point}. + getEndScreenPosition(options) { + return this.layer.translateBufferPosition(this.getEndBufferPosition(), options); + } + + // Extended: Returns a {Boolean} indicating whether the marker has a tail. + hasTail() { + return this.bufferMarker.hasTail(); + } + + // Extended: Plants the marker's tail at the current head position. After calling + // the marker's tail position will be its head position at the time of the + // call, regardless of where the marker's head is moved. + plantTail() { + return this.bufferMarker.plantTail(); + } + + // Extended: Removes the marker's tail. After calling the marker's head position + // will be reported as its current tail position until the tail is planted + // again. + clearTail() { + return this.bufferMarker.clearTail(); + } + + toString() { + return `[Marker ${this.id}, bufferRange: ${this.getBufferRange()}, screenRange: ${this.getScreenRange()}}]`; + } + + /* + Section: Private + */ + + inspect() { + return this.toString(); + } + + notifyObservers(textChanged) { + if (!this.hasChangeObservers) { return; } + if (textChanged == null) { textChanged = false; } + + const newHeadBufferPosition = this.getHeadBufferPosition(); + const newHeadScreenPosition = this.getHeadScreenPosition(); + const newTailBufferPosition = this.getTailBufferPosition(); + const newTailScreenPosition = this.getTailScreenPosition(); + const isValid = this.isValid(); + + if ((isValid === this.wasValid) && + newHeadBufferPosition.isEqual(this.oldHeadBufferPosition) && + newHeadScreenPosition.isEqual(this.oldHeadScreenPosition) && + newTailBufferPosition.isEqual(this.oldTailBufferPosition) && + newTailScreenPosition.isEqual(this.oldTailScreenPosition)) { return; } + + const changeEvent = { + oldHeadScreenPosition: this.oldHeadScreenPosition, newHeadScreenPosition, + oldTailScreenPosition: this.oldTailScreenPosition, newTailScreenPosition, + oldHeadBufferPosition: this.oldHeadBufferPosition, newHeadBufferPosition, + oldTailBufferPosition: this.oldTailBufferPosition, newTailBufferPosition, + textChanged, + wasValid: this.wasValid, + isValid + }; + + this.oldHeadBufferPosition = newHeadBufferPosition; + this.oldHeadScreenPosition = newHeadScreenPosition; + this.oldTailBufferPosition = newTailBufferPosition; + this.oldTailScreenPosition = newTailScreenPosition; + this.wasValid = isValid; + + return this.emitter.emit('did-change', changeEvent); + } +} + +module.exports = DisplayMarker; diff --git a/src/helpers.js b/src/helpers.js index 094685521d..8066aa1f10 100644 --- a/src/helpers.js +++ b/src/helpers.js @@ -1,4 +1,4 @@ -const {Patch} = require('superstring') +const {Patch} = require('@pulsar-edit/superstring') const Range = require('./range') const {traversal} = require('./point-helpers') @@ -25,7 +25,7 @@ exports.debounce = function debounce (fn, wait) { } } -exports.spliceArray = function (array, start, removedCount, insertedItems = []) { +exports.spliceArray = function spliceArray(array, start, removedCount, insertedItems = []) { const oldLength = array.length const insertedCount = insertedItems.length removedCount = Math.min(removedCount, oldLength - start) @@ -49,7 +49,7 @@ exports.spliceArray = function (array, start, removedCount, insertedItems = []) } } -exports.patchFromChanges = function (changes) { +exports.patchFromChanges = function patchFromChanges(changes) { const patch = new Patch() for (let i = 0; i < changes.length; i++) { const {oldStart, oldEnd, oldText, newStart, newEnd, newText} = changes[i] @@ -60,7 +60,7 @@ exports.patchFromChanges = function (changes) { return patch } -exports.normalizePatchChanges = function (changes) { +exports.normalizePatchChanges = function normalizePatchChanges(changes) { return changes.map((change) => new TextChange( Range(change.oldStart, change.oldEnd), @@ -70,7 +70,7 @@ exports.normalizePatchChanges = function (changes) { ) } -exports.extentForText = function (text) { +exports.extentForText = function extentForText(text) { let lastLineStartIndex = 0 let row = 0 LF_REGEX.lastIndex = 0 diff --git a/src/is-character-pair.coffee b/src/is-character-pair.coffee deleted file mode 100644 index 02969d5ee7..0000000000 --- a/src/is-character-pair.coffee +++ /dev/null @@ -1,31 +0,0 @@ -module.exports = (character1, character2) -> - charCodeA = character1.charCodeAt(0) - charCodeB = character2.charCodeAt(0) - isSurrogatePair(charCodeA, charCodeB) or - isVariationSequence(charCodeA, charCodeB) or - isCombinedCharacter(charCodeA, charCodeB) - -isCombinedCharacter = (charCodeA, charCodeB) -> - not isCombiningCharacter(charCodeA) and isCombiningCharacter(charCodeB) - -isSurrogatePair = (charCodeA, charCodeB) -> - isHighSurrogate(charCodeA) and isLowSurrogate(charCodeB) - -isVariationSequence = (charCodeA, charCodeB) -> - not isVariationSelector(charCodeA) and isVariationSelector(charCodeB) - -isHighSurrogate = (charCode) -> - 0xD800 <= charCode <= 0xDBFF - -isLowSurrogate = (charCode) -> - 0xDC00 <= charCode <= 0xDFFF - -isVariationSelector = (charCode) -> - 0xFE00 <= charCode <= 0xFE0F - -isCombiningCharacter = (charCode) -> - 0x0300 <= charCode <= 0x036F or - 0x1AB0 <= charCode <= 0x1AFF or - 0x1DC0 <= charCode <= 0x1DFF or - 0x20D0 <= charCode <= 0x20FF or - 0xFE20 <= charCode <= 0xFE2F diff --git a/src/is-character-pair.js b/src/is-character-pair.js new file mode 100644 index 0000000000..5566ce36cd --- /dev/null +++ b/src/is-character-pair.js @@ -0,0 +1,46 @@ +// TODO: This file was created by bulk-decaffeinate. +// Sanity-check the conversion and remove this comment. +/* + * decaffeinate suggestions: + * DS102: Remove unnecessary code created because of implicit returns + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md + */ +module.exports = function(character1, character2) { + const charCodeA = character1.charCodeAt(0); + const charCodeB = character2.charCodeAt(0); + return isSurrogatePair(charCodeA, charCodeB) || + isVariationSequence(charCodeA, charCodeB) || + isCombinedCharacter(charCodeA, charCodeB); +}; + +function isCombinedCharacter (charCodeA, charCodeB) { + return !isCombiningCharacter(charCodeA) && isCombiningCharacter(charCodeB); +} + +function isSurrogatePair (charCodeA, charCodeB) { + return isHighSurrogate(charCodeA) && isLowSurrogate(charCodeB); +} + +function isVariationSequence (charCodeA, charCodeB) { + return !isVariationSelector(charCodeA) && isVariationSelector(charCodeB); +} + +function isHighSurrogate (charCode) { + return 0xD800 <= charCode && charCode <= 0xDBFF; +} + +function isLowSurrogate (charCode) { + return 0xDC00 <= charCode && charCode <= 0xDFFF; +} + +function isVariationSelector (charCode) { + return 0xFE00 <= charCode && charCode <= 0xFE0F; +} + +function isCombiningCharacter (charCode) { + return (0x0300 <= charCode && charCode <= 0x036F) || + (0x1AB0 <= charCode && charCode <= 0x1AFF) || + (0x1DC0 <= charCode && charCode <= 0x1DFF) || + (0x20D0 <= charCode && charCode <= 0x20FF) || + (0xFE20 <= charCode && charCode <= 0xFE2F); +} diff --git a/src/marker-layer.coffee b/src/marker-layer.coffee deleted file mode 100644 index 64d17a4bc3..0000000000 --- a/src/marker-layer.coffee +++ /dev/null @@ -1,425 +0,0 @@ -{clone} = require "underscore-plus" -{Emitter} = require 'event-kit' -Point = require "./point" -Range = require "./range" -Marker = require "./marker" -{MarkerIndex} = require "superstring" -{intersectSet} = require "./set-helpers" - -SerializationVersion = 2 - -# Public: *Experimental:* A container for a related set of markers. -# -# This API is experimental and subject to change on any release. -module.exports = -class MarkerLayer - @deserialize: (delegate, state) -> - store = new MarkerLayer(delegate, 0) - store.deserialize(state) - store - - @deserializeSnapshot: (snapshot) -> - result = {} - for layerId, markerSnapshots of snapshot - result[layerId] = {} - for markerId, markerSnapshot of markerSnapshots - result[layerId][markerId] = clone(markerSnapshot) - result[layerId][markerId].range = Range.fromObject(markerSnapshot.range) - result - - ### - Section: Lifecycle - ### - - constructor: (@delegate, @id, options) -> - @maintainHistory = options?.maintainHistory ? false - @destroyInvalidatedMarkers = options?.destroyInvalidatedMarkers ? false - @role = options?.role - @delegate.registerSelectionsMarkerLayer(this) if @role is "selections" - @persistent = options?.persistent ? false - @emitter = new Emitter - @index = new MarkerIndex - @markersById = {} - @markersWithChangeListeners = new Set - @markersWithDestroyListeners = new Set - @displayMarkerLayers = new Set - @destroyed = false - @emitCreateMarkerEvents = false - - # Public: Create a copy of this layer with markers in the same state and - # locations. - copy: -> - copy = @delegate.addMarkerLayer({@maintainHistory, @role}) - for markerId, marker of @markersById - snapshot = marker.getSnapshot(null) - copy.createMarker(marker.getRange(), marker.getSnapshot()) - copy - - # Public: Destroy this layer. - destroy: -> - return if @destroyed - @clear() - @delegate.markerLayerDestroyed(this) - @displayMarkerLayers.forEach (displayMarkerLayer) -> displayMarkerLayer.destroy() - @displayMarkerLayers.clear() - @destroyed = true - @emitter.emit 'did-destroy' - @emitter.clear() - - # Public: Remove all markers from this layer. - clear: -> - @markersWithDestroyListeners.forEach (marker) -> marker.destroy() - @markersWithDestroyListeners.clear() - @markersById = {} - @index = new MarkerIndex - @displayMarkerLayers.forEach (layer) -> layer.didClearBufferMarkerLayer() - @delegate.markersUpdated(this) - - # Public: Determine whether this layer has been destroyed. - isDestroyed: -> - @destroyed - - isAlive: -> - not @destroyed - - ### - Section: Querying - ### - - # Public: Get an existing marker by its id. - # - # Returns a {Marker}. - getMarker: (id) -> - @markersById[id] - - # Public: Get all existing markers on the marker layer. - # - # Returns an {Array} of {Marker}s. - getMarkers: -> - marker for id, marker of @markersById - - # Public: Get the number of markers in the marker layer. - # - # Returns a {Number}. - getMarkerCount: -> - Object.keys(@markersById).length - - # Public: Find markers in the layer conforming to the given parameters. - # - # See the documentation for {TextBuffer::findMarkers}. - findMarkers: (params) -> - markerIds = null - - for key in Object.keys(params) - value = params[key] - switch key - when 'startPosition' - markerIds = filterSet(markerIds, @index.findStartingAt(Point.fromObject(value))) - when 'endPosition' - markerIds = filterSet(markerIds, @index.findEndingAt(Point.fromObject(value))) - when 'startsInRange' - {start, end} = Range.fromObject(value) - markerIds = filterSet(markerIds, @index.findStartingIn(start, end)) - when 'endsInRange' - {start, end} = Range.fromObject(value) - markerIds = filterSet(markerIds, @index.findEndingIn(start, end)) - when 'containsPoint', 'containsPosition' - position = Point.fromObject(value) - markerIds = filterSet(markerIds, @index.findContaining(position, position)) - when 'containsRange' - {start, end} = Range.fromObject(value) - markerIds = filterSet(markerIds, @index.findContaining(start, end)) - when 'intersectsRange' - {start, end} = Range.fromObject(value) - markerIds = filterSet(markerIds, @index.findIntersecting(start, end)) - when 'startRow' - markerIds = filterSet(markerIds, @index.findStartingIn(Point(value, 0), Point(value, Infinity))) - when 'endRow' - markerIds = filterSet(markerIds, @index.findEndingIn(Point(value, 0), Point(value, Infinity))) - when 'intersectsRow' - markerIds = filterSet(markerIds, @index.findIntersecting(Point(value, 0), Point(value, Infinity))) - when 'intersectsRowRange' - markerIds = filterSet(markerIds, @index.findIntersecting(Point(value[0], 0), Point(value[1], Infinity))) - when 'containedInRange' - {start, end} = Range.fromObject(value) - markerIds = filterSet(markerIds, @index.findContainedIn(start, end)) - else - continue - delete params[key] - - markerIds ?= new Set(Object.keys(@markersById)) - - result = [] - markerIds.forEach (markerId) => - marker = @markersById[markerId] - return unless marker.matchesParams(params) - result.push(marker) - result.sort (a, b) -> a.compare(b) - - # Public: Get the role of the marker layer e.g. `atom.selection`. - # - # Returns a {String}. - getRole: -> - @role - - ### - Section: Marker creation - ### - - # Public: Create a marker with the given range. - # - # * `range` A {Range} or range-compatible {Array} - # * `options` 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` {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, options={}) -> - @createMarker(@delegate.clipRange(range), Marker.extractParams(options)) - - # Public: Create a marker at with its head at the given position with no tail. - # - # * `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` {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={}) -> - position = @delegate.clipPosition(position) - options = Marker.extractParams(options) - options.tailed = false - @createMarker(@delegate.clipRange(new Range(position, position)), options) - - ### - Section: Event subscription - ### - - # Public: Subscribe to be notified asynchronously whenever markers are - # created, updated, or destroyed on this layer. *Prefer this method for - # optimal performance when interacting with layers that could contain large - # numbers of markers.* - # - # * `callback` A {Function} that will be called with no arguments when changes - # occur on this layer. - # - # Subscribers are notified once, asynchronously when any number of changes - # occur in a given tick of the event loop. You should re-query the layer - # to determine the state of markers in which you're interested in. It may - # be counter-intuitive, but this is much more efficient than subscribing to - # events on individual markers, which are expensive to deliver. - # - # Returns a {Disposable}. - onDidUpdate: (callback) -> - @emitter.on 'did-update', callback - - # Public: Subscribe to be notified synchronously whenever markers are created - # on this layer. *Avoid this method for optimal performance when interacting - # with layers that could contain large numbers of markers.* - # - # * `callback` A {Function} that will be called with a {Marker} whenever a - # new marker is created. - # - # You should prefer {::onDidUpdate} when synchronous notifications aren't - # absolutely necessary. - # - # Returns a {Disposable}. - onDidCreateMarker: (callback) -> - @emitCreateMarkerEvents = true - @emitter.on 'did-create-marker', callback - - # Public: Subscribe to be notified synchronously when this layer is destroyed. - # - # Returns a {Disposable}. - onDidDestroy: (callback) -> - @emitter.on 'did-destroy', callback - - ### - Section: Private - TextBuffer interface - ### - - splice: (start, oldExtent, newExtent) -> - invalidated = @index.splice(start, oldExtent, newExtent) - invalidated.touch.forEach (id) => - marker = @markersById[id] - if invalidated[marker.getInvalidationStrategy()]?.has(id) - if @destroyInvalidatedMarkers - marker.destroy() - else - marker.valid = false - - restoreFromSnapshot: (snapshots, alwaysCreate) -> - return unless snapshots? - - snapshotIds = Object.keys(snapshots) - existingMarkerIds = Object.keys(@markersById) - - for id in snapshotIds - snapshot = snapshots[id] - if alwaysCreate - @createMarker(snapshot.range, snapshot, true) - continue - - if marker = @markersById[id] - marker.update(marker.getRange(), snapshot, true, true) - else - {marker} = snapshot - if marker - @markersById[marker.id] = marker - {range} = snapshot - @index.insert(marker.id, range.start, range.end) - marker.update(marker.getRange(), snapshot, true, true) - @emitter.emit 'did-create-marker', marker if @emitCreateMarkerEvents - else - newMarker = @createMarker(snapshot.range, snapshot, true) - - for id in existingMarkerIds - if (marker = @markersById[id]) and (not snapshots[id]?) - marker.destroy(true) - - createSnapshot: -> - result = {} - ranges = @index.dump() - for id in Object.keys(@markersById) - marker = @markersById[id] - result[id] = marker.getSnapshot(Range.fromObject(ranges[id])) - result - - emitChangeEvents: (snapshot) -> - @markersWithChangeListeners.forEach (marker) -> - unless marker.isDestroyed() # event handlers could destroy markers - marker.emitChangeEvent(snapshot?[marker.id]?.range, true, false) - - serialize: -> - ranges = @index.dump() - markersById = {} - for id in Object.keys(@markersById) - marker = @markersById[id] - snapshot = marker.getSnapshot(Range.fromObject(ranges[id]), false) - markersById[id] = snapshot - - {@id, @maintainHistory, @role, @persistent, markersById, version: SerializationVersion} - - deserialize: (state) -> - return unless state.version is SerializationVersion - @id = state.id - @maintainHistory = state.maintainHistory - @role = state.role - @delegate.registerSelectionsMarkerLayer(this) if @role is "selections" - @persistent = state.persistent - for id, markerState of state.markersById - range = Range.fromObject(markerState.range) - delete markerState.range - @addMarker(id, range, markerState) - return - - ### - Section: Private - Marker interface - ### - - markerUpdated: -> - @delegate.markersUpdated(this) - - destroyMarker: (marker, suppressMarkerLayerUpdateEvents=false) -> - if @markersById.hasOwnProperty(marker.id) - delete @markersById[marker.id] - @index.remove(marker.id) - @markersWithChangeListeners.delete(marker) - @markersWithDestroyListeners.delete(marker) - @displayMarkerLayers.forEach (displayMarkerLayer) -> displayMarkerLayer.destroyMarker(marker.id) - @delegate.markersUpdated(this) unless suppressMarkerLayerUpdateEvents - - hasMarker: (id) -> - not @destroyed and @index.has(id) - - getMarkerRange: (id) -> - Range.fromObject(@index.getRange(id)) - - getMarkerStartPosition: (id) -> - Point.fromObject(@index.getStart(id)) - - getMarkerEndPosition: (id) -> - Point.fromObject(@index.getEnd(id)) - - compareMarkers: (id1, id2) -> - @index.compare(id1, id2) - - setMarkerRange: (id, range) -> - {start, end} = Range.fromObject(range) - start = @delegate.clipPosition(start) - end = @delegate.clipPosition(end) - @index.remove(id) - @index.insert(id, start, end) - - setMarkerIsExclusive: (id, exclusive) -> - @index.setExclusive(id, exclusive) - - createMarker: (range, params, suppressMarkerLayerUpdateEvents=false) -> - id = @delegate.getNextMarkerId() - marker = @addMarker(id, range, params) - @delegate.markerCreated(this, marker) - @delegate.markersUpdated(this) unless suppressMarkerLayerUpdateEvents - marker.trackDestruction = @trackDestructionInOnDidCreateMarkerCallbacks ? false - @emitter.emit 'did-create-marker', marker if @emitCreateMarkerEvents - marker.trackDestruction = false - marker - - ### - Section: Internal - ### - - addMarker: (id, range, params) -> - range = Range.fromObject(range) - Point.assertValid(range.start) - Point.assertValid(range.end) - @index.insert(id, range.start, range.end) - @markersById[id] = new Marker(id, this, range, params) - - emitUpdateEvent: -> - @emitter.emit('did-update') - -filterSet = (set1, set2) -> - if set1 - intersectSet(set1, set2) - set1 - else - set2 diff --git a/src/marker-layer.js b/src/marker-layer.js new file mode 100644 index 0000000000..9ff06fba5f --- /dev/null +++ b/src/marker-layer.js @@ -0,0 +1,552 @@ +const {clone} = require("underscore-plus"); +const {Emitter} = require('event-kit'); +const Point = require("./point"); +const Range = require("./range"); +const Marker = require("./marker"); +const {MarkerIndex} = require("@pulsar-edit/superstring") +const {intersectSet} = require("./set-helpers"); +const SerializationVersion = 2; + +// Public: *Experimental:* A container for a related set of markers. + +// This API is experimental and subject to change on any release. +class MarkerLayer { + static deserialize(delegate, state) { + var store; + store = new MarkerLayer(delegate, 0); + store.deserialize(state); + return store; + } + + static deserializeSnapshot(snapshot) { + var layerId, markerId, markerSnapshot, markerSnapshots, result; + result = {}; + for (layerId in snapshot) { + markerSnapshots = snapshot[layerId]; + result[layerId] = {}; + for (markerId in markerSnapshots) { + markerSnapshot = markerSnapshots[markerId]; + result[layerId][markerId] = clone(markerSnapshot); + result[layerId][markerId].range = Range.fromObject(markerSnapshot.range); + } + } + return result; + } + + /* + Section: Lifecycle + */ + constructor( + delegate, + id, + { + destroyInvalidatedMarkers = false, + maintainHistory = false, + persistent = false, + role + } = {} + ) { + this.delegate = delegate; + this.id = id; + this.maintainHistory = maintainHistory; + this.destroyInvalidatedMarkers = destroyInvalidatedMarkers; + this.role = role; + if (this.role === "selections") { + this.delegate.registerSelectionsMarkerLayer(this); + } + this.persistent = persistent; + + this.emitter = new Emitter(); + this.index = new MarkerIndex(); + this.markersById = {}; + this.markersWithChangeListeners = new Set(); + this.markersWithDestroyListeners = new Set(); + this.displayMarkerLayers = new Set(); + this.destroyed = false; + this.emitCreateMarkerEvents = false; + } + + // Public: Create a copy of this layer with markers in the same state and + // locations. + copy() { + let copy = this.delegate.addMarkerLayer({ + maintainHistory: this.maintainHistory, + role: this.role + }); + for (let marker of Object.values(this.markersById)) { + let snapshot = marker.getSnapshot(null); + copy.createMarker(marker.getRange(), snapshot); + } + return copy; + } + + // Public: Destroy this layer. + destroy() { + if (this.destroyed) { + return; + } + this.clear(); + this.delegate.markerLayerDestroyed(this); + this.displayMarkerLayers.forEach(function(displayMarkerLayer) { + return displayMarkerLayer.destroy(); + }); + this.displayMarkerLayers.clear(); + this.destroyed = true; + this.emitter.emit('did-destroy'); + return this.emitter.clear(); + } + + // Public: Remove all markers from this layer. + clear() { + this.markersWithDestroyListeners.forEach(function(marker) { + return marker.destroy(); + }); + this.markersWithDestroyListeners.clear(); + this.markersById = {}; + this.index = new MarkerIndex(); + this.displayMarkerLayers.forEach(function(layer) { + return layer.didClearBufferMarkerLayer(); + }); + return this.delegate.markersUpdated(this); + } + + // Public: Determine whether this layer has been destroyed. + isDestroyed() { + return this.destroyed; + } + + isAlive() { + return !this.destroyed; + } + + /* + Section: Querying + */ + // Public: Get an existing marker by its id. + + // Returns a {Marker}. + getMarker(id) { + return this.markersById[id]; + } + + // Public: Get all existing markers on the marker layer. + + // Returns an {Array} of {Marker}s. + getMarkers() { + return Object.values(this.markersById); + } + + // Public: Get the number of markers in the marker layer. + + // Returns a {Number}. + getMarkerCount() { + return Object.keys(this.markersById).length; + } + + // Public: Find markers in the layer conforming to the given parameters. + + // See the documentation for {TextBuffer::findMarkers}. + findMarkers(params) { + let markerIds = null; + for (let [key, value] of Object.entries(params)) { + let start, end, position; + switch (key) { + case 'startPosition': + markerIds = filterSet( + markerIds, + this.index.findStartingAt(Point.fromObject(value)) + ); + break; + case 'endPosition': + markerIds = filterSet( + markerIds, + this.index.findEndingAt(Point.fromObject(value)) + ); + break; + case 'startsInRange': + ({start, end} = Range.fromObject(value)); + markerIds = filterSet(markerIds, this.index.findStartingIn(start, end)); + break; + case 'endsInRange': + ({start, end} = Range.fromObject(value)); + markerIds = filterSet(markerIds, this.index.findEndingIn(start, end)); + break; + case 'containsPoint': + case 'containsPosition': + position = Point.fromObject(value); + markerIds = filterSet(markerIds, this.index.findContaining(position, position)); + break; + case 'containsRange': + ({start, end} = Range.fromObject(value)); + markerIds = filterSet(markerIds, this.index.findContaining(start, end)); + break; + case 'intersectsRange': + ({start, end} = Range.fromObject(value)); + markerIds = filterSet(markerIds, this.index.findIntersecting(start, end)); + break; + case 'startRow': + markerIds = filterSet(markerIds, this.index.findStartingIn(Point(value, 0), Point(value, 2e308))); + break; + case 'endRow': + markerIds = filterSet(markerIds, this.index.findEndingIn(Point(value, 0), Point(value, 2e308))); + break; + case 'intersectsRow': + markerIds = filterSet(markerIds, this.index.findIntersecting(Point(value, 0), Point(value, 2e308))); + break; + case 'intersectsRowRange': + markerIds = filterSet(markerIds, this.index.findIntersecting(Point(value[0], 0), Point(value[1], 2e308))); + break; + case 'containedInRange': + ({start, end} = Range.fromObject(value)); + markerIds = filterSet(markerIds, this.index.findContainedIn(start, end)); + break; + default: + continue; + } + delete params[key]; + } + if (markerIds == null) { + markerIds = new Set(Object.keys(this.markersById)); + } + let result = []; + for (let markerId of markerIds) { + let marker = this.markersById[markerId]; + if (!marker.matchesParams(params)) continue; + result.push(marker); + } + result.sort((a, b) => a.compare(b)); + return result; + } + + // Public: Get the role of the marker layer e.g. `atom.selection`. + + // Returns a {String}. + getRole() { + return this.role; + } + + /* + Section: Marker creation + */ + // Public: Create a marker with the given range. + // + // * `range` A {Range} or range-compatible {Array} + // * `options` 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` {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, options = {}) { + return this.createMarker(this.delegate.clipRange(range), Marker.extractParams(options)); + } + + // Public: Create a marker at with its head at the given position with no tail. + // + // * `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` {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 = {}) { + position = this.delegate.clipPosition(position); + options = Marker.extractParams(options); + options.tailed = false; + return this.createMarker(this.delegate.clipRange(new Range(position, position)), options); + } + + /* + Section: Event subscription + */ + // Public: Subscribe to be notified asynchronously whenever markers are + // created, updated, or destroyed on this layer. *Prefer this method for + // optimal performance when interacting with layers that could contain large + // numbers of markers.* + // + // * `callback` A {Function} that will be called with no arguments when changes + // occur on this layer. + // + // Subscribers are notified once, asynchronously when any number of changes + // occur in a given tick of the event loop. You should re-query the layer + // to determine the state of markers in which you're interested in. It may + // be counter-intuitive, but this is much more efficient than subscribing to + // events on individual markers, which are expensive to deliver. + + // Returns a {Disposable}. + onDidUpdate(callback) { + return this.emitter.on('did-update', callback); + } + + // Public: Subscribe to be notified synchronously whenever markers are created + // on this layer. *Avoid this method for optimal performance when interacting + // with layers that could contain large numbers of markers.* + // + // * `callback` A {Function} that will be called with a {Marker} whenever a + // new marker is created. + // + // You should prefer {::onDidUpdate} when synchronous notifications aren't + // absolutely necessary. + + // Returns a {Disposable}. + onDidCreateMarker(callback) { + this.emitCreateMarkerEvents = true; + return this.emitter.on('did-create-marker', callback); + } + + // Public: Subscribe to be notified synchronously when this layer is destroyed. + + // Returns a {Disposable}. + onDidDestroy(callback) { + return this.emitter.on('did-destroy', callback); + } + + /* + Section: Private - TextBuffer interface + */ + splice(start, oldExtent, newExtent) { + let invalidated = this.index.splice(start, oldExtent, newExtent); + for (let id of invalidated.touch) { + let marker = this.markersById[id]; + if (invalidated[marker.getInvalidationStrategy()]?.has(id)) { + if (this.destroyInvalidatedMarkers) { + marker.destroy(); + } else { + marker.valid = false; + } + } + } + } + + restoreFromSnapshot(snapshots, alwaysCreate) { + if (snapshots == null) return; + + let snapshotIds = Object.keys(snapshots); + let existingMarkerIds = Object.keys(this.markersById); + + for (let id of snapshotIds) { + let snapshot = snapshots[id]; + if (alwaysCreate) { + this.createMarker(snapshot.range, snapshot, true); + continue; + } + let marker = this.markersById[id]; + if (marker) { + marker.update(marker.getRange(), snapshot, true, true); + } else { + marker = snapshot.marker; + if (marker) { + this.markersById[marker.id] = marker; + let { range } = snapshot; + this.index.insert(marker.id, range.start, range.end); + marker.update(marker.getRange(), snapshot, true, true); + if (this.emitCreateMarkerEvents) { + this.emitter.emit('did-create-marker', marker); + } + } else { + this.createMarker(snapshot.range, snapshot, true); + } + } + } + + for (let id of existingMarkerIds) { + let marker = this.markersById[id]; + if (marker && !snapshots[id]) { + marker.destroy(true); + } + } + } + + createSnapshot() { + let result = {}; + let ranges = this.index.dump(); + for (let id of Object.keys(this.markersById)) { + let marker = this.markersById[id]; + result[id] = marker.getSnapshot(Range.fromObject(ranges[id])); + } + return result; + } + + emitChangeEvents(snapshot) { + this.markersWithChangeListeners.forEach(function(marker) { + if (!marker.isDestroyed()) { // event handlers could destroy markers + return marker.emitChangeEvent(snapshot?.[marker.id]?.range, true, false); + } + }); + } + + serialize() { + let ranges = this.index.dump(); + let markersById = {}; + for (let id of Object.keys(this.markersById)) { + let marker = this.markersById[id]; + let snapshot = marker.getSnapshot(Range.fromObject(ranges[id]), false); + markersById[id] = snapshot; + } + return { + id: this.id, + maintainHistory: this.maintainHistory, + role: this.role, + persistent: this.persistent, + markersById, + version: SerializationVersion + }; + } + + deserialize(state) { + // var id, markerState, range, ref; + if (state.version !== SerializationVersion) { + return; + } + this.id = state.id; + this.maintainHistory = state.maintainHistory; + this.role = state.role; + if (this.role === "selections") { + this.delegate.registerSelectionsMarkerLayer(this); + } + this.persistent = state.persistent; + for (let [id, markerState] of Object.entries(state.markersById)) { + let range = Range.fromObject(markerState.range); + // `markerState` is frozen, so instead of deleting its `range` we'll + // create a new object and copy all properties _except_ `range`. + let { range: oldRange, ...params } = markerState; + this.addMarker(id, range, { ...params }); + } + } + + /* + Section: Private - Marker interface + */ + markerUpdated() { + return this.delegate.markersUpdated(this); + } + + destroyMarker(marker, suppressMarkerLayerUpdateEvents = false) { + if (this.markersById.hasOwnProperty(marker.id)) { + delete this.markersById[marker.id]; + this.index.remove(marker.id); + this.markersWithChangeListeners.delete(marker); + this.markersWithDestroyListeners.delete(marker); + this.displayMarkerLayers.forEach(function(displayMarkerLayer) { + displayMarkerLayer.destroyMarker(marker.id); + }); + if (!suppressMarkerLayerUpdateEvents) { + this.delegate.markersUpdated(this); + } + } + } + + hasMarker(id) { + return !this.destroyed && this.index.has(id); + } + + getMarkerRange(id) { + return Range.fromObject(this.index.getRange(id)); + } + + getMarkerStartPosition(id) { + return Point.fromObject(this.index.getStart(id)); + } + + getMarkerEndPosition(id) { + return Point.fromObject(this.index.getEnd(id)); + } + + compareMarkers(id1, id2) { + return this.index.compare(id1, id2); + } + + setMarkerRange(id, range) { + id = parseInt(id); + let {start, end} = Range.fromObject(range); + start = this.delegate.clipPosition(start); + end = this.delegate.clipPosition(end); + this.index.remove(id); + return this.index.insert(id, start, end); + } + + setMarkerIsExclusive(id, exclusive) { + return this.index.setExclusive(id, exclusive); + } + + createMarker(range, params, suppressMarkerLayerUpdateEvents = false) { + let id = this.delegate.getNextMarkerId(); + let marker = this.addMarker(id, range, params); + this.delegate.markerCreated(this, marker); + if (!suppressMarkerLayerUpdateEvents) { + this.delegate.markersUpdated(this); + } + marker.trackDestruction = this.trackDestructionInOnDidCreateMarkerCallbacks ?? false; + if (this.emitCreateMarkerEvents) { + this.emitter.emit('did-create-marker', marker); + } + marker.trackDestruction = false; + return marker; + } + + /* + Section: Internal + */ + addMarker(id, range, params) { + id = parseInt(id); + range = Range.fromObject(range); + Point.assertValid(range.start); + Point.assertValid(range.end); + this.index.insert(id, range.start, range.end); + return this.markersById[id] = new Marker(id, this, range, params); + } + + emitUpdateEvent() { + return this.emitter.emit('did-update'); + } + +}; + +function filterSet(set1, set2) { + if (set1) { + intersectSet(set1, set2); + return set1; + } else { + return set2; + } +}; + +module.exports = MarkerLayer; diff --git a/src/marker.coffee b/src/marker.coffee deleted file mode 100644 index a6017ddf26..0000000000 --- a/src/marker.coffee +++ /dev/null @@ -1,435 +0,0 @@ -{extend, isEqual, omit, pick, size} = require 'underscore-plus' -{Emitter} = require 'event-kit' -Delegator = require 'delegato' -Point = require './point' -Range = require './range' -Grim = require 'grim' - -OptionKeys = new Set(['reversed', 'tailed', 'invalidate', 'exclusive']) - -# Private: Represents a buffer annotation that remains logically stationary -# even as the buffer changes. This is used to represent cursors, folds, snippet -# targets, misspelled words, and anything else that needs to track a logical -# location in the buffer over time. -# -# Head and Tail: -# Markers always have a *head* and sometimes have a *tail*. If you think of a -# marker as an editor selection, the tail is the part that's stationary and the -# head is the part that moves when the mouse is moved. A marker without a tail -# always reports an empty range at the head position. A marker with a head position -# greater than the tail is in a "normal" orientation. If the head precedes the -# tail the marker is in a "reversed" orientation. -# -# Validity: -# Markers are considered *valid* when they are first created. Depending on the -# invalidation strategy you choose, certain changes to the buffer can cause a -# marker to become invalid, for example if the text surrounding the marker is -# deleted. See {TextBuffer::markRange} for invalidation strategies. -module.exports = -class Marker - Delegator.includeInto(this) - - @extractParams: (inputParams) -> - outputParams = {} - containsCustomProperties = false - if inputParams? - for key in Object.keys(inputParams) - if OptionKeys.has(key) - outputParams[key] = inputParams[key] - else if key is 'clipDirection' or key is 'skipSoftWrapIndentation' - # TODO: Ignore these two keys for now. Eventually, when the - # deprecation below will be gone, we can remove this conditional as - # well, and just return standard marker properties. - else - containsCustomProperties = true - outputParams.properties ?= {} - outputParams.properties[key] = inputParams[key] - - # TODO: Remove both this deprecation and the conditional above on the - # release after the one where we'll ship `DisplayLayer`. - if containsCustomProperties - Grim.deprecate(""" - Assigning custom properties to a marker when creating/copying it is - deprecated. Please, consider storing the custom properties you need in - some other object in your package, keyed by the marker's id property. - """) - - outputParams - - @delegatesMethods 'containsPoint', 'containsRange', 'intersectsRow', toMethod: 'getRange' - - constructor: (@id, @layer, range, params, exclusivitySet = false) -> - {@tailed, @reversed, @valid, @invalidate, @exclusive, @properties} = params - @emitter = new Emitter - @tailed ?= true - @reversed ?= false - @valid ?= true - @invalidate ?= 'overlap' - @properties ?= {} - @hasChangeObservers = false - Object.freeze(@properties) - @layer.setMarkerIsExclusive(@id, @isExclusive()) unless exclusivitySet - - ### - Section: Event Subscription - ### - - # Public: Invoke the given callback when the marker is destroyed. - # - # * `callback` {Function} to be called when the marker is destroyed. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidDestroy: (callback) -> - @layer.markersWithDestroyListeners.add(this) - @emitter.on 'did-destroy', callback - - # Public: Invoke the given callback when the state of the marker changes. - # - # * `callback` {Function} to be called when the marker changes. - # * `event` {Object} with the following keys: - # * `oldHeadPosition` {Point} representing the former head position - # * `newHeadPosition` {Point} representing the new head position - # * `oldTailPosition` {Point} representing the former tail position - # * `newTailPosition` {Point} representing the new tail position - # * `wasValid` {Boolean} indicating whether the marker was valid before the change - # * `isValid` {Boolean} indicating whether the marker is now valid - # * `hadTail` {Boolean} indicating whether the marker had a tail before the change - # * `hasTail` {Boolean} indicating whether the marker now has a tail - # * `oldProperties` {Object} containing the marker's custom properties before the change. - # * `newProperties` {Object} containing the marker's custom properties after the change. - # * `textChanged` {Boolean} indicating whether this change was caused by a textual change - # to the buffer or whether the marker was manipulated directly via its public API. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidChange: (callback) -> - unless @hasChangeObservers - @previousEventState = @getSnapshot(@getRange()) - @hasChangeObservers = true - @layer.markersWithChangeListeners.add(this) - @emitter.on 'did-change', callback - - # Public: Returns the current {Range} of the marker. The range is immutable. - getRange: -> @layer.getMarkerRange(@id) - - # Public: Sets the range of the marker. - # - # * `range` A {Range} or range-compatible {Array}. The range will be clipped - # before it is assigned. - # * `params` (optional) An {Object} with the following keys: - # * `reversed` {Boolean} indicating the marker will to be in a reversed - # orientation. - # * `exclusive` {Boolean} indicating that changes occurring at either end of - # the marker will be considered *outside* the marker rather than inside. - # This defaults to `false` unless the marker's invalidation strategy is - # `inside` or the marker has no tail, in which case it defaults to `true`. - setRange: (range, params) -> - params ?= {} - @update(@getRange(), {reversed: params.reversed, tailed: true, range: Range.fromObject(range, true), exclusive: params.exclusive}) - - # Public: Returns a {Point} representing the marker's current head position. - getHeadPosition: -> - if @reversed - @getStartPosition() - else - @getEndPosition() - - # Public: Sets the head position of the marker. - # - # * `position` A {Point} or point-compatible {Array}. The position will be - # clipped before it is assigned. - setHeadPosition: (position) -> - position = Point.fromObject(position) - oldRange = @getRange() - params = {} - - if @hasTail() - if @isReversed() - if position.isLessThan(oldRange.end) - params.range = new Range(position, oldRange.end) - else - params.reversed = false - params.range = new Range(oldRange.end, position) - else - if position.isLessThan(oldRange.start) - params.reversed = true - params.range = new Range(position, oldRange.start) - else - params.range = new Range(oldRange.start, position) - else - params.range = new Range(position, position) - @update(oldRange, params) - - # Public: Returns a {Point} representing the marker's current tail position. - # If the marker has no tail, the head position will be returned instead. - getTailPosition: -> - if @reversed - @getEndPosition() - else - @getStartPosition() - - # Public: Sets the tail position of the marker. If the marker doesn't have a - # tail, it will after calling this method. - # - # * `position` A {Point} or point-compatible {Array}. The position will be - # clipped before it is assigned. - setTailPosition: (position) -> - position = Point.fromObject(position) - oldRange = @getRange() - params = {tailed: true} - - if @reversed - if position.isLessThan(oldRange.start) - params.reversed = false - params.range = new Range(position, oldRange.start) - else - params.range = new Range(oldRange.start, position) - else - if position.isLessThan(oldRange.end) - params.range = new Range(position, oldRange.end) - else - params.reversed = true - params.range = new Range(oldRange.end, position) - - @update(oldRange, params) - - # Public: Returns a {Point} representing the start position of the marker, - # which could be the head or tail position, depending on its orientation. - getStartPosition: -> @layer.getMarkerStartPosition(@id) - - # Public: Returns a {Point} representing the end position of the marker, - # which could be the head or tail position, depending on its orientation. - getEndPosition: -> @layer.getMarkerEndPosition(@id) - - # Public: Removes the marker's tail. After calling the marker's head position - # will be reported as its current tail position until the tail is planted - # again. - clearTail: -> - headPosition = @getHeadPosition() - @update(@getRange(), {tailed: false, reversed: false, range: Range(headPosition, headPosition)}) - - # Public: Plants the marker's tail at the current head position. After calling - # the marker's tail position will be its head position at the time of the - # call, regardless of where the marker's head is moved. - plantTail: -> - unless @hasTail() - headPosition = @getHeadPosition() - @update(@getRange(), {tailed: true, range: new Range(headPosition, headPosition)}) - - # Public: Returns a {Boolean} indicating whether the head precedes the tail. - isReversed: -> - @tailed and @reversed - - # Public: Returns a {Boolean} indicating whether the marker has a tail. - hasTail: -> - @tailed - - # Public: Is the marker valid? - # - # Returns a {Boolean}. - isValid: -> - not @isDestroyed() and @valid - - # Public: Is the marker destroyed? - # - # Returns a {Boolean}. - isDestroyed: -> - not @layer.hasMarker(@id) - - # Public: Returns a {Boolean} indicating whether changes that occur exactly at - # the marker's head or tail cause it to move. - isExclusive: -> - if @exclusive? - @exclusive - else - @getInvalidationStrategy() is 'inside' or not @hasTail() - - # Public: Returns a {Boolean} indicating whether this marker is equivalent to - # another marker, meaning they have the same range and options. - # - # * `other` {Marker} other marker - isEqual: (other) -> - @invalidate is other.invalidate and - @tailed is other.tailed and - @reversed is other.reversed and - @exclusive is other.exclusive and - isEqual(@properties, other.properties) and - @getRange().isEqual(other.getRange()) - - # Public: Get the invalidation strategy for this marker. - # - # Valid values include: `never`, `surround`, `overlap`, `inside`, and `touch`. - # - # Returns a {String}. - getInvalidationStrategy: -> - @invalidate - - # Public: Returns an {Object} containing any custom properties associated with - # the marker. - getProperties: -> - @properties - - # Public: Merges an {Object} containing new properties into the marker's - # existing properties. - # - # * `properties` {Object} - setProperties: (properties) -> - @update(@getRange(), properties: extend({}, @properties, properties)) - - # Public: Creates and returns a new {Marker} with the same properties as this - # marker. - # - # * `params` {Object} - copy: (options={}) -> - snapshot = @getSnapshot() - options = Marker.extractParams(options) - @layer.createMarker(@getRange(), extend( - {} - snapshot, - options, - properties: extend({}, snapshot.properties, options.properties) - )) - - # Public: Destroys the marker, causing it to emit the 'destroyed' event. - destroy: (suppressMarkerLayerUpdateEvents) -> - return if @isDestroyed() - - if @trackDestruction - error = new Error - Error.captureStackTrace(error) - @destroyStackTrace = error.stack - - @layer.destroyMarker(this, suppressMarkerLayerUpdateEvents) - @emitter.emit 'did-destroy' - @emitter.clear() - - # Public: Compares this marker to another based on their ranges. - # - # * `other` {Marker} - compare: (other) -> - @layer.compareMarkers(@id, other.id) - - # Returns whether this marker matches the given parameters. The parameters - # are the same as {MarkerLayer::findMarkers}. - matchesParams: (params) -> - for key in Object.keys(params) - return false unless @matchesParam(key, params[key]) - true - - # Returns whether this marker matches the given parameter name and value. - # The parameters are the same as {MarkerLayer::findMarkers}. - matchesParam: (key, value) -> - switch key - when 'startPosition' - @getStartPosition().isEqual(value) - when 'endPosition' - @getEndPosition().isEqual(value) - when 'containsPoint', 'containsPosition' - @containsPoint(value) - when 'containsRange' - @containsRange(value) - when 'startRow' - @getStartPosition().row is value - when 'endRow' - @getEndPosition().row is value - when 'intersectsRow' - @intersectsRow(value) - when 'invalidate', 'reversed', 'tailed' - isEqual(@[key], value) - when 'valid' - @isValid() is value - else - isEqual(@properties[key], value) - - update: (oldRange, {range, reversed, tailed, valid, exclusive, properties}, textChanged=false, suppressMarkerLayerUpdateEvents=false) -> - return if @isDestroyed() - - oldRange = Range.fromObject(oldRange) - range = Range.fromObject(range) if range? - - wasExclusive = @isExclusive() - updated = propertiesChanged = false - - if range? and not range.isEqual(oldRange) - @layer.setMarkerRange(@id, range) - updated = true - - if reversed? and reversed isnt @reversed - @reversed = reversed - updated = true - - if tailed? and tailed isnt @tailed - @tailed = tailed - updated = true - - if valid? and valid isnt @valid - @valid = valid - updated = true - - if exclusive? and exclusive isnt @exclusive - @exclusive = exclusive - updated = true - - if wasExclusive isnt @isExclusive() - @layer.setMarkerIsExclusive(@id, @isExclusive()) - updated = true - - if properties? and not isEqual(properties, @properties) - @properties = Object.freeze(properties) - propertiesChanged = true - updated = true - - @emitChangeEvent(range ? oldRange, textChanged, propertiesChanged) - @layer.markerUpdated() if updated and not suppressMarkerLayerUpdateEvents - updated - - getSnapshot: (range, includeMarker=true) -> - snapshot = {range, @properties, @reversed, @tailed, @valid, @invalidate, @exclusive} - snapshot.marker = this if includeMarker - Object.freeze(snapshot) - - toString: -> - "[Marker #{@id}, #{@getRange()}]" - - ### - Section: Private - ### - - inspect: -> - @toString() - - emitChangeEvent: (currentRange, textChanged, propertiesChanged) -> - return unless @hasChangeObservers - oldState = @previousEventState - - currentRange ?= @getRange() - - return false unless propertiesChanged or - oldState.valid isnt @valid or - oldState.tailed isnt @tailed or - oldState.reversed isnt @reversed or - oldState.range.compare(currentRange) isnt 0 - - newState = @previousEventState = @getSnapshot(currentRange) - - if oldState.reversed - oldHeadPosition = oldState.range.start - oldTailPosition = oldState.range.end - else - oldHeadPosition = oldState.range.end - oldTailPosition = oldState.range.start - - if newState.reversed - newHeadPosition = newState.range.start - newTailPosition = newState.range.end - else - newHeadPosition = newState.range.end - newTailPosition = newState.range.start - - @emitter.emit("did-change", { - wasValid: oldState.valid, isValid: newState.valid - hadTail: oldState.tailed, hasTail: newState.tailed - oldProperties: oldState.properties, newProperties: newState.properties - oldHeadPosition, newHeadPosition, oldTailPosition, newTailPosition - textChanged - }) - true diff --git a/src/marker.js b/src/marker.js new file mode 100644 index 0000000000..cc8b24c70a --- /dev/null +++ b/src/marker.js @@ -0,0 +1,552 @@ +const {extend, isEqual} = require('underscore-plus'); +const {Emitter} = require('event-kit'); +const Delegator = require('delegato'); +const Point = require('./point'); +const Range = require('./range'); +const Grim = require('grim'); + +const OptionKeys = new Set(['reversed', 'tailed', 'invalidate', 'exclusive']); + +// Private: Represents a buffer annotation that remains logically stationary +// even as the buffer changes. This is used to represent cursors, folds, snippet +// targets, misspelled words, and anything else that needs to track a logical +// location in the buffer over time. +// +// Head and Tail: +// Markers always have a *head* and sometimes have a *tail*. If you think of a +// marker as an editor selection, the tail is the part that's stationary and the +// head is the part that moves when the mouse is moved. A marker without a tail +// always reports an empty range at the head position. A marker with a head position +// greater than the tail is in a "normal" orientation. If the head precedes the +// tail the marker is in a "reversed" orientation. +// +// Validity: +// Markers are considered *valid* when they are first created. Depending on the +// invalidation strategy you choose, certain changes to the buffer can cause a +// marker to become invalid, for example if the text surrounding the marker is +// deleted. See {TextBuffer::markRange} for invalidation strategies. +class Marker { + static extractParams(inputParams) { + const outputParams = {}; + let containsCustomProperties = false; + if (inputParams != null) { + for (var key of Object.keys(inputParams)) { + if (OptionKeys.has(key)) { + outputParams[key] = inputParams[key]; + } else if ((key === 'clipDirection') || (key === 'skipSoftWrapIndentation')) { + // TODO: Ignore these two keys for now. Eventually, when the + // deprecation below will be gone, we can remove this conditional as + // well, and just return standard marker properties. + } else { + containsCustomProperties = true; + if (outputParams.properties == null) { outputParams.properties = {}; } + outputParams.properties[key] = inputParams[key]; + } + } + } + + // TODO: Remove both this deprecation and the conditional above on the + // release after the one where we'll ship `DisplayLayer`. + if (containsCustomProperties) { + Grim.deprecate(`\ +Assigning custom properties to a marker when creating/copying it is +deprecated. Please, consider storing the custom properties you need in +some other object in your package, keyed by the marker's id property.\ +`); + } + + return outputParams; + } + + constructor(id, layer, _range, params, exclusivitySet) { + // The `_range` parameter is kept in place just to keep the API stable, + // but it's not used; the marker asks its layer for its range later on + // via `::getRange`. + this.id = id; + this.layer = layer; + if (exclusivitySet == null) { exclusivitySet = false; } + ({tailed: this.tailed, reversed: this.reversed, valid: this.valid, invalidate: this.invalidate, exclusive: this.exclusive, properties: this.properties} = params); + this.emitter = new Emitter; + if (this.tailed == null) { this.tailed = true; } + if (this.reversed == null) { this.reversed = false; } + if (this.valid == null) { this.valid = true; } + if (this.invalidate == null) { this.invalidate = 'overlap'; } + if (this.properties == null) { this.properties = {}; } + this.hasChangeObservers = false; + Object.freeze(this.properties); + if (!exclusivitySet) { this.layer.setMarkerIsExclusive(this.id, this.isExclusive()); } + } + + /* + Section: Event Subscription + */ + + // Public: Invoke the given callback when the marker is destroyed. + // + // * `callback` {Function} to be called when the marker is destroyed. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidDestroy(callback) { + this.layer.markersWithDestroyListeners.add(this); + return this.emitter.on('did-destroy', callback); + } + + // Public: Invoke the given callback when the state of the marker changes. + // + // * `callback` {Function} to be called when the marker changes. + // * `event` {Object} with the following keys: + // * `oldHeadPosition` {Point} representing the former head position + // * `newHeadPosition` {Point} representing the new head position + // * `oldTailPosition` {Point} representing the former tail position + // * `newTailPosition` {Point} representing the new tail position + // * `wasValid` {Boolean} indicating whether the marker was valid before the change + // * `isValid` {Boolean} indicating whether the marker is now valid + // * `hadTail` {Boolean} indicating whether the marker had a tail before the change + // * `hasTail` {Boolean} indicating whether the marker now has a tail + // * `oldProperties` {Object} containing the marker's custom properties before the change. + // * `newProperties` {Object} containing the marker's custom properties after the change. + // * `textChanged` {Boolean} indicating whether this change was caused by a textual change + // to the buffer or whether the marker was manipulated directly via its public API. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidChange(callback) { + if (!this.hasChangeObservers) { + this.previousEventState = this.getSnapshot(this.getRange()); + this.hasChangeObservers = true; + this.layer.markersWithChangeListeners.add(this); + } + return this.emitter.on('did-change', callback); + } + + // Public: Returns the current {Range} of the marker. The range is immutable. + getRange() { + return this.layer.getMarkerRange(this.id); + } + + // Public: Sets the range of the marker. + // + // * `range` A {Range} or range-compatible {Array}. The range will be clipped + // before it is assigned. + // * `params` (optional) An {Object} with the following keys: + // * `reversed` {Boolean} indicating the marker will to be in a reversed + // orientation. + // * `exclusive` {Boolean} indicating that changes occurring at either end of + // the marker will be considered *outside* the marker rather than inside. + // This defaults to `false` unless the marker's invalidation strategy is + // `inside` or the marker has no tail, in which case it defaults to `true`. + setRange(range, params) { + if (params == null) { params = {}; } + return this.update(this.getRange(), { + reversed: params.reversed, + tailed: true, + range: Range.fromObject(range, true), + exclusive: params.exclusive + }); + } + + // Public: Returns a {Point} representing the marker's current head position. + getHeadPosition() { + if (this.reversed) { + return this.getStartPosition(); + } else { + return this.getEndPosition(); + } + } + + // Public: Sets the head position of the marker. + // + // * `position` A {Point} or point-compatible {Array}. The position will be + // clipped before it is assigned. + setHeadPosition(position) { + position = Point.fromObject(position); + const oldRange = this.getRange(); + const params = {}; + + if (this.hasTail()) { + if (this.isReversed()) { + if (position.isLessThan(oldRange.end)) { + params.range = new Range(position, oldRange.end); + } else { + params.reversed = false; + params.range = new Range(oldRange.end, position); + } + } else { + if (position.isLessThan(oldRange.start)) { + params.reversed = true; + params.range = new Range(position, oldRange.start); + } else { + params.range = new Range(oldRange.start, position); + } + } + } else { + params.range = new Range(position, position); + } + return this.update(oldRange, params); + } + + // Public: Returns a {Point} representing the marker's current tail position. + // If the marker has no tail, the head position will be returned instead. + getTailPosition() { + if (this.reversed) { + return this.getEndPosition(); + } else { + return this.getStartPosition(); + } + } + + // Public: Sets the tail position of the marker. If the marker doesn't have a + // tail, it will after calling this method. + // + // * `position` A {Point} or point-compatible {Array}. The position will be + // clipped before it is assigned. + setTailPosition(position) { + position = Point.fromObject(position); + const oldRange = this.getRange(); + const params = {tailed: true}; + + if (this.reversed) { + if (position.isLessThan(oldRange.start)) { + params.reversed = false; + params.range = new Range(position, oldRange.start); + } else { + params.range = new Range(oldRange.start, position); + } + } else { + if (position.isLessThan(oldRange.end)) { + params.range = new Range(position, oldRange.end); + } else { + params.reversed = true; + params.range = new Range(oldRange.end, position); + } + } + + return this.update(oldRange, params); + } + + // Public: Returns a {Point} representing the start position of the marker, + // which could be the head or tail position, depending on its orientation. + getStartPosition() { + return this.layer.getMarkerStartPosition(this.id); + } + + // Public: Returns a {Point} representing the end position of the marker, + // which could be the head or tail position, depending on its orientation. + getEndPosition() { + return this.layer.getMarkerEndPosition(this.id); + } + + // Public: Removes the marker's tail. After calling the marker's head position + // will be reported as its current tail position until the tail is planted + // again. + clearTail() { + const headPosition = this.getHeadPosition(); + return this.update(this.getRange(), { + tailed: false, + reversed: false, + range: Range(headPosition, headPosition) + }); + } + + // Public: Plants the marker's tail at the current head position. After calling + // the marker's tail position will be its head position at the time of the + // call, regardless of where the marker's head is moved. + plantTail() { + if (!this.hasTail()) { + const headPosition = this.getHeadPosition(); + return this.update(this.getRange(), { + tailed: true, + range: new Range(headPosition, headPosition) + }); + } + } + + // Public: Returns a {Boolean} indicating whether the head precedes the tail. + isReversed() { + return this.tailed && this.reversed; + } + + // Public: Returns a {Boolean} indicating whether the marker has a tail. + hasTail() { + return this.tailed; + } + + // Public: Is the marker valid? + // + // Returns a {Boolean}. + isValid() { + return !this.isDestroyed() && this.valid; + } + + // Public: Is the marker destroyed? + // + // Returns a {Boolean}. + isDestroyed() { + return !this.layer.hasMarker(this.id); + } + + // Public: Returns a {Boolean} indicating whether changes that occur exactly at + // the marker's head or tail cause it to move. + isExclusive() { + if (this.exclusive != null) { + return this.exclusive; + } else { + return (this.getInvalidationStrategy() === 'inside') || !this.hasTail(); + } + } + + // Public: Returns a {Boolean} indicating whether this marker is equivalent to + // another marker, meaning they have the same range and options. + // + // * `other` {Marker} other marker + isEqual(other) { + return (this.invalidate === other.invalidate) && + (this.tailed === other.tailed) && + (this.reversed === other.reversed) && + (this.exclusive === other.exclusive) && + isEqual(this.properties, other.properties) && + this.getRange().isEqual(other.getRange()); + } + + // Public: Get the invalidation strategy for this marker. + // + // Valid values include: `never`, `surround`, `overlap`, `inside`, and `touch`. + // + // Returns a {String}. + getInvalidationStrategy() { + return this.invalidate; + } + + // Public: Returns an {Object} containing any custom properties associated with + // the marker. + getProperties() { + return this.properties; + } + + // Public: Merges an {Object} containing new properties into the marker's + // existing properties. + // + // * `properties` {Object} + setProperties(properties) { + return this.update(this.getRange(), { + properties: extend({}, this.properties, properties) + }); + } + + // Public: Creates and returns a new {Marker} with the same properties as this + // marker. + // + // * `params` {Object} + copy(options) { + if (options == null) { options = {}; } + const snapshot = this.getSnapshot(); + options = Marker.extractParams(options); + return this.layer.createMarker(this.getRange(), extend( + {}, + snapshot, + options, + {properties: extend({}, snapshot.properties, options.properties)} + )); + } + + // Public: Destroys the marker, causing it to emit the 'destroyed' event. + destroy(suppressMarkerLayerUpdateEvents) { + if (this.isDestroyed()) { return; } + + if (this.trackDestruction) { + const error = new Error; + Error.captureStackTrace(error); + this.destroyStackTrace = error.stack; + } + + this.layer.destroyMarker(this, suppressMarkerLayerUpdateEvents); + this.emitter.emit('did-destroy'); + return this.emitter.clear(); + } + + // Public: Compares this marker to another based on their ranges. + // + // * `other` {Marker} + compare(other) { + return this.layer.compareMarkers(this.id, other.id); + } + + // Returns whether this marker matches the given parameters. The parameters + // are the same as {MarkerLayer::findMarkers}. + matchesParams(params) { + for (var key of Object.keys(params)) { + if (!this.matchesParam(key, params[key])) { return false; } + } + return true; + } + + // Returns whether this marker matches the given parameter name and value. + // The parameters are the same as {MarkerLayer::findMarkers}. + matchesParam(key, value) { + switch (key) { + case 'startPosition': + return this.getStartPosition().isEqual(value); + case 'endPosition': + return this.getEndPosition().isEqual(value); + case 'containsPoint': case 'containsPosition': + return this.containsPoint(value); + case 'containsRange': + return this.containsRange(value); + case 'startRow': + return this.getStartPosition().row === value; + case 'endRow': + return this.getEndPosition().row === value; + case 'intersectsRow': + return this.intersectsRow(value); + case 'invalidate': case 'reversed': case 'tailed': + return isEqual(this[key], value); + case 'valid': + return this.isValid() === value; + default: + return isEqual(this.properties[key], value); + } + } + + update(oldRange, {range, reversed, tailed, valid, exclusive, properties}, textChanged, suppressMarkerLayerUpdateEvents) { + let propertiesChanged; + if (textChanged == null) { textChanged = false; } + if (suppressMarkerLayerUpdateEvents == null) { suppressMarkerLayerUpdateEvents = false; } + if (this.isDestroyed()) { return; } + + oldRange = Range.fromObject(oldRange); + if (range != null) { range = Range.fromObject(range); } + + const wasExclusive = this.isExclusive(); + let updated = (propertiesChanged = false); + + if ((range != null) && !range.isEqual(oldRange)) { + this.layer.setMarkerRange(this.id, range); + updated = true; + } + + if ((reversed != null) && (reversed !== this.reversed)) { + this.reversed = reversed; + updated = true; + } + + if ((tailed != null) && (tailed !== this.tailed)) { + this.tailed = tailed; + updated = true; + } + + if ((valid != null) && (valid !== this.valid)) { + this.valid = valid; + updated = true; + } + + if ((exclusive != null) && (exclusive !== this.exclusive)) { + this.exclusive = exclusive; + updated = true; + } + + if (wasExclusive !== this.isExclusive()) { + this.layer.setMarkerIsExclusive(this.id, this.isExclusive()); + updated = true; + } + + if ((properties != null) && !isEqual(properties, this.properties)) { + this.properties = Object.freeze(properties); + propertiesChanged = true; + updated = true; + } + + this.emitChangeEvent(range ?? oldRange, textChanged, propertiesChanged); + if (updated && !suppressMarkerLayerUpdateEvents) { + this.layer.markerUpdated(); + } + return updated; + } + + getSnapshot(range, includeMarker) { + if (includeMarker == null) { includeMarker = true; } + const snapshot = { + range, + properties: this.properties, + reversed: this.reversed, + tailed: this.tailed, + valid: this.valid, + invalidate: this.invalidate, + exclusive: this.exclusive + }; + if (includeMarker) { snapshot.marker = this; } + return Object.freeze(snapshot); + } + + toString() { + return `[Marker ${this.id}, ${this.getRange()}]`; + } + + /* + Section: Private + */ + + inspect() { + return this.toString(); + } + + emitChangeEvent(currentRange, textChanged, propertiesChanged) { + let newHeadPosition, newTailPosition, oldHeadPosition, oldTailPosition; + if (!this.hasChangeObservers) { return; } + const oldState = this.previousEventState; + + if (currentRange == null) { currentRange = this.getRange(); } + + if ( + !propertiesChanged && + (oldState.valid === this.valid) && + (oldState.tailed === this.tailed) && + (oldState.reversed === this.reversed) && + (oldState.range.compare(currentRange) === 0) + ) { + return false; + } + + const newState = (this.previousEventState = this.getSnapshot(currentRange)); + + if (oldState.reversed) { + oldHeadPosition = oldState.range.start; + oldTailPosition = oldState.range.end; + } else { + oldHeadPosition = oldState.range.end; + oldTailPosition = oldState.range.start; + } + + if (newState.reversed) { + newHeadPosition = newState.range.start; + newTailPosition = newState.range.end; + } else { + newHeadPosition = newState.range.end; + newTailPosition = newState.range.start; + } + + this.emitter.emit("did-change", { + wasValid: oldState.valid, + isValid: newState.valid, + hadTail: oldState.tailed, + hasTail: newState.tailed, + oldProperties: oldState.properties, + newProperties: newState.properties, + oldHeadPosition, + newHeadPosition, + oldTailPosition, + newTailPosition, + textChanged + }); + return true; + } +} + +Delegator.includeInto(Marker); + +Marker.delegatesMethods( + 'containsPoint', + 'containsRange', + 'intersectsRow', + {toMethod: 'getRange'} +); + +module.exports = Marker; diff --git a/src/point-helpers.coffee b/src/point-helpers.coffee deleted file mode 100644 index 3f2a86949b..0000000000 --- a/src/point-helpers.coffee +++ /dev/null @@ -1,62 +0,0 @@ -Point = require './point' - -exports.compare = (a, b) -> - if a.row is b.row - compareNumbers(a.column, b.column) - else - compareNumbers(a.row, b.row) - -compareNumbers = (a, b) -> - if a < b - -1 - else if a > b - 1 - else - 0 - -exports.isEqual = (a, b) -> - a.row is b.row and a.column is b.column - -exports.traverse = (start, distance) -> - if distance.row is 0 - Point(start.row, start.column + distance.column) - else - Point(start.row + distance.row, distance.column) - -exports.traversal = (end, start) -> - if end.row is start.row - Point(0, end.column - start.column) - else - Point(end.row - start.row, end.column) - -NEWLINE_REG_EXP = /\n/g - -exports.characterIndexForPoint = (text, point) -> - row = point.row - column = point.column - NEWLINE_REG_EXP.lastIndex = 0 - while row-- > 0 - unless NEWLINE_REG_EXP.exec(text) - return text.length - - NEWLINE_REG_EXP.lastIndex + column - -exports.clipNegativePoint = (point) -> - if point.row < 0 - Point(0, 0) - else if point.column < 0 - Point(point.row, 0) - else - point - -exports.max = (a, b) -> - if exports.compare(a, b) >= 0 - a - else - b - -exports.min = (a, b) -> - if exports.compare(a, b) <= 0 - a - else - b diff --git a/src/point-helpers.js b/src/point-helpers.js new file mode 100644 index 0000000000..981265a1b1 --- /dev/null +++ b/src/point-helpers.js @@ -0,0 +1,84 @@ +// TODO: This file was created by bulk-decaffeinate. +// Sanity-check the conversion and remove this comment. +/* + * decaffeinate suggestions: + * DS102: Remove unnecessary code created because of implicit returns + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md + */ +const Point = require('./point'); + +exports.compare = function compare(a, b) { + if (a.row === b.row) { + return compareNumbers(a.column, b.column); + } else { + return compareNumbers(a.row, b.row); + } +}; + +function compareNumbers(a, b) { + if (a < b) { + return -1; + } else if (a > b) { + return 1; + } else { + return 0; + } +} + +exports.isEqual = (a, b) => (a.row === b.row) && (a.column === b.column); + +exports.traverse = function traverse(start, distance) { + if (distance.row === 0) { + return Point(start.row, start.column + distance.column); + } else { + return Point(start.row + distance.row, distance.column); + } +}; + +exports.traversal = function traversal(end, start) { + if (end.row === start.row) { + return Point(0, end.column - start.column); + } else { + return Point(end.row - start.row, end.column); + } +}; + +const NEWLINE_REG_EXP = /\n/g; + +exports.characterIndexForPoint = function characterIndexForPoint(text, point) { + let { row, column } = point; + NEWLINE_REG_EXP.lastIndex = 0; + while (row-- > 0) { + if (!NEWLINE_REG_EXP.exec(text)) { + return text.length; + } + } + + return NEWLINE_REG_EXP.lastIndex + column; +}; + +exports.clipNegativePoint = function clipNegativePoint(point) { + if (point.row < 0) { + return Point(0, 0); + } else if (point.column < 0) { + return Point(point.row, 0); + } else { + return point; + } +}; + +exports.max = function max(a, b) { + if (exports.compare(a, b) >= 0) { + return a; + } else { + return b; + } +}; + +exports.min = function min(a, b) { + if (exports.compare(a, b) <= 0) { + return a; + } else { + return b; + } +}; diff --git a/src/point.coffee b/src/point.coffee deleted file mode 100644 index 86f9688192..0000000000 --- a/src/point.coffee +++ /dev/null @@ -1,268 +0,0 @@ -# Public: Represents a point in a buffer in row/column coordinates. -# -# Every public method that takes a point also accepts a *point-compatible* -# {Array}. This means a 2-element array containing {Number}s representing the -# row and column. So the following are equivalent: -# -# ```coffee -# new Point(1, 2) -# [1, 2] # Point compatible Array -# ``` -module.exports = -class Point - ### - Section: Properties - ### - - # Public: A zero-indexed {Number} representing the row of the {Point}. - row: null - - # Public: A zero-indexed {Number} representing the column of the {Point}. - column: null - - ### - Section: Construction - ### - - # Public: Convert any point-compatible object to a {Point}. - # - # * `object` This can be an object that's already a {Point}, in which case it's - # simply returned, or an array containing two {Number}s representing the - # row and column. - # * `copy` An optional boolean indicating whether to force the copying of objects - # that are already points. - # - # Returns: A {Point} based on the given object. - @fromObject: (object, copy) -> - if object instanceof Point - if copy then object.copy() else object - else - if Array.isArray(object) - [row, column] = object - else - {row, column} = object - - new Point(row, column) - - ### - Section: Comparison - ### - - # Public: Returns the given {Point} that is earlier in the buffer. - # - # * `point1` {Point} - # * `point2` {Point} - @min: (point1, point2) -> - point1 = @fromObject(point1) - point2 = @fromObject(point2) - if point1.isLessThanOrEqual(point2) - point1 - else - point2 - - @max: (point1, point2) -> - point1 = Point.fromObject(point1) - point2 = Point.fromObject(point2) - if point1.compare(point2) >= 0 - point1 - else - point2 - - @assertValid: (point) -> - unless isNumber(point.row) and isNumber(point.column) - throw new TypeError("Invalid Point: #{point}") - - @ZERO: Object.freeze(new Point(0, 0)) - - @INFINITY: Object.freeze(new Point(Infinity, Infinity)) - - ### - Section: Construction - ### - - # Public: Construct a {Point} object - # - # * `row` {Number} row - # * `column` {Number} column - constructor: (row=0, column=0) -> - unless this instanceof Point - return new Point(row, column) - @row = row - @column = column - - # Public: Returns a new {Point} with the same row and column. - copy: -> - new Point(@row, @column) - - # Public: Returns a new {Point} with the row and column negated. - negate: -> - new Point(-@row, -@column) - - ### - Section: Operations - ### - - # Public: Makes this point immutable and returns itself. - # - # Returns an immutable version of this {Point} - freeze: -> - Object.freeze(this) - - # Public: Build and return a new point by adding the rows and columns of - # the given point. - # - # * `other` A {Point} whose row and column will be added to this point's row - # and column to build the returned point. - # - # Returns a {Point}. - translate: (other) -> - {row, column} = Point.fromObject(other) - new Point(@row + row, @column + column) - - # Public: Build and return a new {Point} by traversing the rows and columns - # specified by the given point. - # - # * `other` A {Point} providing the rows and columns to traverse by. - # - # This method differs from the direct, vector-style addition offered by - # {::translate}. Rather than adding the rows and columns directly, it derives - # the new point from traversing in "typewriter space". At the end of every row - # traversed, a carriage return occurs that returns the columns to 0 before - # continuing the traversal. - # - # ## Examples - # - # Traversing 0 rows, 2 columns: - # `new Point(10, 5).traverse(new Point(0, 2)) # => [10, 7]` - # - # Traversing 2 rows, 2 columns. Note the columns reset from 0 before adding: - # `new Point(10, 5).traverse(new Point(2, 2)) # => [12, 2]` - # - # Returns a {Point}. - traverse: (other) -> - other = Point.fromObject(other) - row = @row + other.row - if other.row is 0 - column = @column + other.column - else - column = other.column - - new Point(row, column) - - traversalFrom: (other) -> - other = Point.fromObject(other) - if @row is other.row - if @column is Infinity and other.column is Infinity - new Point(0, 0) - else - new Point(0, @column - other.column) - else - new Point(@row - other.row, @column) - - splitAt: (column) -> - if @row is 0 - rightColumn = @column - column - else - rightColumn = @column - - [new Point(0, column), new Point(@row, rightColumn)] - - ### - Section: Comparison - ### - - # Public: - # - # * `other` A {Point} or point-compatible {Array}. - # - # Returns `-1` if this point precedes the argument. - # Returns `0` if this point is equivalent to the argument. - # Returns `1` if this point follows the argument. - compare: (other) -> - other = Point.fromObject(other) - if @row > other.row - 1 - else if @row < other.row - -1 - else - if @column > other.column - 1 - else if @column < other.column - -1 - else - 0 - - # Public: Returns a {Boolean} indicating whether this point has the same row - # and column as the given {Point} or point-compatible {Array}. - # - # * `other` A {Point} or point-compatible {Array}. - isEqual: (other) -> - return false unless other - other = Point.fromObject(other) - @row is other.row and @column is other.column - - # Public: Returns a {Boolean} indicating whether this point precedes the given - # {Point} or point-compatible {Array}. - # - # * `other` A {Point} or point-compatible {Array}. - isLessThan: (other) -> - @compare(other) < 0 - - # Public: Returns a {Boolean} indicating whether this point precedes or is - # equal to the given {Point} or point-compatible {Array}. - # - # * `other` A {Point} or point-compatible {Array}. - isLessThanOrEqual: (other) -> - @compare(other) <= 0 - - # Public: Returns a {Boolean} indicating whether this point follows the given - # {Point} or point-compatible {Array}. - # - # * `other` A {Point} or point-compatible {Array}. - isGreaterThan: (other) -> - @compare(other) > 0 - - # Public: Returns a {Boolean} indicating whether this point follows or is - # equal to the given {Point} or point-compatible {Array}. - # - # * `other` A {Point} or point-compatible {Array}. - isGreaterThanOrEqual: (other) -> - @compare(other) >= 0 - - isZero: -> - @row is 0 and @column is 0 - - isPositive: -> - if @row > 0 - true - else if @row < 0 - false - else - @column > 0 - - isNegative: -> - if @row < 0 - true - else if @row > 0 - false - else - @column < 0 - - ### - Section: Conversion - ### - - # Public: Returns an array of this point's row and column. - toArray: -> - [@row, @column] - - # Public: Returns an array of this point's row and column. - serialize: -> - @toArray() - - # Public: Returns a string representation of the point. - toString: -> - "(#{@row}, #{@column})" - -isNumber = (value) -> - (typeof value is 'number') and (not Number.isNaN(value)) diff --git a/src/point.js b/src/point.js new file mode 100644 index 0000000000..7b3abd5e3d --- /dev/null +++ b/src/point.js @@ -0,0 +1,321 @@ +function isActualNumber (value) { + return (typeof value === 'number') && (!Number.isNaN(value)); +} + +// Public: Represents a point in a buffer in row/column coordinates. +// +// Every public method that takes a point also accepts a *point-compatible* +// {Array}. This means a 2-element array containing {Number}s representing the +// row and column. So the following are equivalent: +// +// ```js +// new Point(1, 2) +// [1, 2] // Point-compatible Array +// ``` +class Point { + /* + Section: Construction + */ + + static ZERO = Object.freeze(new Point(0, 0)); + static INFINITY = Object.freeze(new Point(Infinity, Infinity)); + + // Public: Convert any point-compatible object to a {Point}. + // + // * `object` This can be an object that's already a {Point}, in which case + // it's simply returned; or an array containing two {Number}s representing + // the row and column. + // * `copy` An optional boolean indicating whether to force the copying of + // objects that are already points. + // + // Returns: A {Point} based on the given object. + static fromObject(object, copy) { + if (object instanceof Point) { + return copy ? object.copy() : object; + } else { + let column, row; + if (Array.isArray(object)) { + [row, column] = object; + } else { + ({row, column} = object); + } + + return new Point(row, column); + } + } + + /* + Section: Comparison + */ + + // Public: Returns the given {Point} that occurs earlier in the buffer. + // + // * `point1` {Point} + // * `point2` {Point} + static min(point1, point2) { + point1 = this.fromObject(point1); + point2 = this.fromObject(point2); + if (point1.isLessThanOrEqual(point2)) { + return point1; + } else { + return point2; + } + } + + // Public: Returns the given {Point} that occurs later in the buffer. + // + // * `point1` {Point} + // * `point2` {Point} + static max(point1, point2) { + point1 = Point.fromObject(point1); + point2 = Point.fromObject(point2); + if (point1.compare(point2) >= 0) { + return point1; + } else { + return point2; + } + } + + // Public: Ensure the given {Point} is valid by throwing a `TypeError` if + // either its `row` or its `column` is not an integer. + static assertValid(point) { + if (!isActualNumber(point.row) || !isActualNumber(point.column)) { + throw new TypeError(`Invalid Point: ${point}`); + } + } + + /* + Section: Construction + */ + + // Public: Construct a {Point} object. + // + // * `row` {Number} row + // * `column` {Number} column + constructor(row=0, column=0) { + this.row = row; + this.column = column; + } + + // Public: Returns a new {Point} with the same row and column. + copy() { + return new Point(this.row, this.column); + } + + // Public: Returns a new {Point} with the row and column negated. + negate() { + return new Point(-this.row, -this.column); + } + + /* + Section: Operations + */ + + // Public: Make this point immutable and return itself. + // + // Returns an immutable version of this {Point}. + freeze() { + return Object.freeze(this); + } + + // Public: Build and return a new point by adding the rows and columns of + // the given point. + // + // * `other` A {Point} whose row and column will be added to this point's row + // and column to build the returned point. + // + // Returns a {Point}. + translate(other) { + const {row, column} = Point.fromObject(other); + return new Point(this.row + row, this.column + column); + } + + // Public: Build and return a new {Point} by traversing the rows and columns + // specified by the given point. + // + // * `other` A {Point} providing the rows and columns to traverse by. + // + // This method differs from the direct, vector-style addition offered by + // {::translate}. Rather than adding the rows and columns directly, it derives + // the new point from traversing in "typewriter space". At the end of every row + // traversed, a carriage return occurs that returns the columns to 0 before + // continuing the traversal. + // + // ## Examples + // + // Traversing 0 rows, 2 columns: + // `new Point(10, 5).traverse(new Point(0, 2)) # => [10, 7]` + // + // Traversing 2 rows, 2 columns. Note the columns reset from 0 before adding: + // `new Point(10, 5).traverse(new Point(2, 2)) # => [12, 2]` + // + // Returns a {Point}. + traverse(other) { + let column; + other = Point.fromObject(other); + const row = this.row + other.row; + if (other.row === 0) { + column = this.column + other.column; + } else { + ({ + column + } = other); + } + + return new Point(row, column); + } + + traversalFrom(other) { + other = Point.fromObject(other); + if (this.row === other.row) { + if ((this.column === Infinity) && (other.column === Infinity)) { + return new Point(0, 0); + } else { + return new Point(0, this.column - other.column); + } + } else { + return new Point(this.row - other.row, this.column); + } + } + + splitAt(column) { + let rightColumn; + if (this.row === 0) { + rightColumn = this.column - column; + } else { + rightColumn = this.column; + } + + return [new Point(0, column), new Point(this.row, rightColumn)]; + } + + /* + Section: Comparison + */ + + // Public: + // + // * `other` A {Point} or point-compatible {Array}. + // + // Returns `-1` if this point precedes the argument. + // Returns `0` if this point is equivalent to the argument. + // Returns `1` if this point follows the argument. + compare(other) { + other = Point.fromObject(other); + if (this.row > other.row) { + return 1; + } else if (this.row < other.row) { + return -1; + } else { + if (this.column > other.column) { + return 1; + } else if (this.column < other.column) { + return -1; + } else { + return 0; + } + } + } + + // Public: Returns a {Boolean} indicating whether this point has the same row + // and column as the given {Point} or point-compatible {Array}. + // + // * `other` A {Point} or point-compatible {Array}. + isEqual(other) { + if (!other) { return false; } + other = Point.fromObject(other); + return (this.row === other.row) && (this.column === other.column); + } + + // Public: Returns a {Boolean} indicating whether this point precedes the given + // {Point} or point-compatible {Array}. + // + // * `other` A {Point} or point-compatible {Array}. + isLessThan(other) { + return this.compare(other) < 0; + } + + // Public: Returns a {Boolean} indicating whether this point precedes or is + // equal to the given {Point} or point-compatible {Array}. + // + // * `other` A {Point} or point-compatible {Array}. + isLessThanOrEqual(other) { + return this.compare(other) <= 0; + } + + // Public: Returns a {Boolean} indicating whether this point follows the given + // {Point} or point-compatible {Array}. + // + // * `other` A {Point} or point-compatible {Array}. + isGreaterThan(other) { + return this.compare(other) > 0; + } + + // Public: Returns a {Boolean} indicating whether this point follows or is + // equal to the given {Point} or point-compatible {Array}. + // + // * `other` A {Point} or point-compatible {Array}. + isGreaterThanOrEqual(other) { + return this.compare(other) >= 0; + } + + isZero() { + return (this.row === 0) && (this.column === 0); + } + + isPositive() { + if (this.row > 0) { + return true; + } else if (this.row < 0) { + return false; + } else { + return this.column > 0; + } + } + + isNegative() { + if (this.row < 0) { + return true; + } else if (this.row > 0) { + return false; + } else { + return this.column < 0; + } + } + + /* + Section: Conversion + */ + + // Public: Returns an array of this point's row and column. + toArray() { + return [this.row, this.column]; + } + + // Public: Returns an array of this point's row and column. + serialize() { + return this.toArray(); + } + + // Public: Returns a string representation of the point. + toString() { + return `(${this.row}, ${this.column})`; + } +} + +// ES5 classes differ from their predecessors in that you are not allowed to +// call them like ordinary functions. Hence we must write this wrapper function +// which delegates to `new Point` whether it was called with `new` or not. +function _Point (...args) { + return new Point(...args); +} +_Point.displayName = 'Point'; +_Point.prototype = Point.prototype; +Object.assign(_Point.prototype, { + row: null, + column: null +}); +// Make the wrapper inherit the parent's static methods. +Object.setPrototypeOf(_Point, Point); + +module.exports = _Point; diff --git a/src/range.coffee b/src/range.coffee deleted file mode 100644 index 10bc381e3d..0000000000 --- a/src/range.coffee +++ /dev/null @@ -1,318 +0,0 @@ -Point = require './point' -newlineRegex = null - -# Public: Represents a region in a buffer in row/column coordinates. -# -# Every public method that takes a range also accepts a *range-compatible* -# {Array}. This means a 2-element array containing {Point}s or point-compatible -# arrays. So the following are equivalent: -# -# ## Examples -# -# ```coffee -# new Range(new Point(0, 1), new Point(2, 3)) -# new Range([0, 1], [2, 3]) -# [[0, 1], [2, 3]] # Range compatible array -# ``` -module.exports = -class Range - ### - Section: Properties - ### - - # Public: A {Point} representing the start of the {Range}. - start: null - - # Public: A {Point} representing the end of the {Range}. - end: null - - ### - Section: Construction - ### - - # Public: Convert any range-compatible object to a {Range}. - # - # * `object` This can be an object that's already a {Range}, in which case it's - # simply returned, or an array containing two {Point}s or point-compatible - # arrays. - # * `copy` An optional boolean indicating whether to force the copying of objects - # that are already ranges. - # - # Returns: A {Range} based on the given object. - @fromObject: (object, copy) -> - if Array.isArray(object) - new this(object[0], object[1]) - else if object instanceof this - if copy then object.copy() else object - else - new this(object.start, object.end) - - # Returns a range based on an optional starting point and the given text. If - # no starting point is given it will be assumed to be [0, 0]. - # - # * `startPoint` (optional) {Point} where the range should start. - # * `text` A {String} after which the range should end. The range will have as many - # rows as the text has lines have an end column based on the length of the - # last line. - # - # Returns: A {Range} - @fromText: (args...) -> - newlineRegex ?= require('./helpers').newlineRegex - - if args.length > 1 - startPoint = Point.fromObject(args.shift()) - else - startPoint = new Point(0, 0) - text = args.shift() - endPoint = startPoint.copy() - lines = text.split(newlineRegex) - if lines.length > 1 - lastIndex = lines.length - 1 - endPoint.row += lastIndex - endPoint.column = lines[lastIndex].length - else - endPoint.column += lines[0].length - new this(startPoint, endPoint) - - # Returns a {Range} that starts at the given point and ends at the - # start point plus the given row and column deltas. - # - # * `startPoint` A {Point} or point-compatible {Array} - # * `rowDelta` A {Number} indicating how many rows to add to the start point - # to get the end point. - # * `columnDelta` A {Number} indicating how many rows to columns to the start - # point to get the end point. - @fromPointWithDelta: (startPoint, rowDelta, columnDelta) -> - startPoint = Point.fromObject(startPoint) - endPoint = new Point(startPoint.row + rowDelta, startPoint.column + columnDelta) - new this(startPoint, endPoint) - - @fromPointWithTraversalExtent: (startPoint, extent) -> - startPoint = Point.fromObject(startPoint) - new this(startPoint, startPoint.traverse(extent)) - - - ### - Section: Serialization and Deserialization - ### - - # Public: Call this with the result of {Range::serialize} to construct a new Range. - # - # * `array` {Array} of params to pass to the {::constructor} - @deserialize: (array) -> - if Array.isArray(array) - new this(array[0], array[1]) - else - new this() - - ### - Section: Construction - ### - - # Public: Construct a {Range} object - # - # * `pointA` {Point} or Point compatible {Array} (default: [0,0]) - # * `pointB` {Point} or Point compatible {Array} (default: [0,0]) - constructor: (pointA = new Point(0, 0), pointB = new Point(0, 0)) -> - unless this instanceof Range - return new Range(pointA, pointB) - - pointA = Point.fromObject(pointA) - pointB = Point.fromObject(pointB) - - if pointA.isLessThanOrEqual(pointB) - @start = pointA - @end = pointB - else - @start = pointB - @end = pointA - - # Public: Returns a new range with the same start and end positions. - copy: -> - new @constructor(@start.copy(), @end.copy()) - - # Public: Returns a new range with the start and end positions negated. - negate: -> - new @constructor(@start.negate(), @end.negate()) - - ### - Section: Serialization and Deserialization - ### - - # Public: Returns a plain javascript object representation of the range. - serialize: -> - [@start.serialize(), @end.serialize()] - - ### - Section: Range Details - ### - - # Public: Is the start position of this range equal to the end position? - # - # Returns a {Boolean}. - isEmpty: -> - @start.isEqual(@end) - - # Public: Returns a {Boolean} indicating whether this range starts and ends on - # the same row. - isSingleLine: -> - @start.row is @end.row - - # Public: Get the number of rows in this range. - # - # Returns a {Number}. - getRowCount: -> - @end.row - @start.row + 1 - - # Public: Returns an array of all rows in the range. - getRows: -> - [@start.row..@end.row] - - ### - Section: Operations - ### - - # Public: Freezes the range and its start and end point so it becomes - # immutable and returns itself. - # - # Returns an immutable version of this {Range} - freeze: -> - @start.freeze() - @end.freeze() - Object.freeze(this) - - # Public: Returns a new range that contains this range and the given range. - # - # * `otherRange` A {Range} or range-compatible {Array} - union: (otherRange) -> - start = if @start.isLessThan(otherRange.start) then @start else otherRange.start - end = if @end.isGreaterThan(otherRange.end) then @end else otherRange.end - new @constructor(start, end) - - # Public: Build and return a new range by translating this range's start and - # end points by the given delta(s). - # - # * `startDelta` A {Point} by which to translate the start of this range. - # * `endDelta` (optional) A {Point} to by which to translate the end of this - # range. If omitted, the `startDelta` will be used instead. - # - # Returns a {Range}. - translate: (startDelta, endDelta=startDelta) -> - new @constructor(@start.translate(startDelta), @end.translate(endDelta)) - - # Public: Build and return a new range by traversing this range's start and - # end points by the given delta. - # - # See {Point::traverse} for details of how traversal differs from translation. - # - # * `delta` A {Point} containing the rows and columns to traverse to derive - # the new range. - # - # Returns a {Range}. - traverse: (delta) -> - new @constructor(@start.traverse(delta), @end.traverse(delta)) - - ### - Section: Comparison - ### - - # Public: Compare two Ranges - # - # * `otherRange` A {Range} or range-compatible {Array}. - # - # Returns `-1` if this range starts before the argument or contains it. - # Returns `0` if this range is equivalent to the argument. - # Returns `1` if this range starts after the argument or is contained by it. - compare: (other) -> - other = @constructor.fromObject(other) - if value = @start.compare(other.start) - value - else - other.end.compare(@end) - - # Public: Returns a {Boolean} indicating whether this range has the same start - # and end points as the given {Range} or range-compatible {Array}. - # - # * `otherRange` A {Range} or range-compatible {Array}. - isEqual: (other) -> - return false unless other? - other = @constructor.fromObject(other) - other.start.isEqual(@start) and other.end.isEqual(@end) - - # Public: Returns a {Boolean} indicating whether this range starts and ends on - # the same row as the argument. - # - # * `otherRange` A {Range} or range-compatible {Array}. - coversSameRows: (other) -> - @start.row is other.start.row and @end.row is other.end.row - - # Public: Determines whether this range intersects with the argument. - # - # * `otherRange` A {Range} or range-compatible {Array} - # * `exclusive` (optional) {Boolean} indicating whether to exclude endpoints - # when testing for intersection. Defaults to `false`. - # - # Returns a {Boolean}. - intersectsWith: (otherRange, exclusive) -> - if exclusive - not (@end.isLessThanOrEqual(otherRange.start) or @start.isGreaterThanOrEqual(otherRange.end)) - else - not (@end.isLessThan(otherRange.start) or @start.isGreaterThan(otherRange.end)) - - # Public: Returns a {Boolean} indicating whether this range contains the given - # range. - # - # * `otherRange` A {Range} or range-compatible {Array} - # * `exclusive` (optional) {Boolean} including that the containment should be exclusive of - # endpoints. Defaults to false. - containsRange: (otherRange, exclusive) -> - {start, end} = @constructor.fromObject(otherRange) - @containsPoint(start, exclusive) and @containsPoint(end, exclusive) - - # Public: Returns a {Boolean} indicating whether this range contains the given - # point. - # - # * `point` A {Point} or point-compatible {Array} - # * `exclusive` (optional) {Boolean} including that the containment should be exclusive of - # endpoints. Defaults to false. - containsPoint: (point, exclusive) -> - point = Point.fromObject(point) - if exclusive - point.isGreaterThan(@start) and point.isLessThan(@end) - else - point.isGreaterThanOrEqual(@start) and point.isLessThanOrEqual(@end) - - # Public: Returns a {Boolean} indicating whether this range intersects the - # given row {Number}. - # - # * `row` Row {Number} - intersectsRow: (row) -> - @start.row <= row <= @end.row - - # Public: Returns a {Boolean} indicating whether this range intersects the - # row range indicated by the given startRow and endRow {Number}s. - # - # * `startRow` {Number} start row - # * `endRow` {Number} end row - intersectsRowRange: (startRow, endRow) -> - [startRow, endRow] = [endRow, startRow] if startRow > endRow - @end.row >= startRow and endRow >= @start.row - - getExtent: -> - @end.traversalFrom(@start) - - ### - Section: Conversion - ### - - toDelta: -> - rows = @end.row - @start.row - if rows is 0 - columns = @end.column - @start.column - else - columns = @end.column - new Point(rows, columns) - - # Public: Returns a string representation of the range. - toString: -> - "[#{@start} - #{@end}]" diff --git a/src/range.js b/src/range.js new file mode 100644 index 0000000000..d040110a67 --- /dev/null +++ b/src/range.js @@ -0,0 +1,381 @@ +const Point = require('./point'); +let newlineRegex = null; + +function __range__(left, right, inclusive) { + let range = []; + let ascending = left < right; + let end = !inclusive ? right : ascending ? right + 1 : right - 1; + for (let i = left; ascending ? i < end : i > end; ascending ? i++ : i--) { + range.push(i); + } + return range; +} + +// Public: Represents a region in a buffer in row/column coordinates. +// +// Every public method that takes a range also accepts a *range-compatible* +// {Array}. This means a 2-element array containing {Point}s or point-compatible +// arrays. So the following are equivalent: +// +// ## Examples +// +// ```js +// new Range(new Point(0, 1), new Point(2, 3)) +// new Range([0, 1], [2, 3]) +// [[0, 1], [2, 3]] // Range-compatible array +// ``` +class Range { + /* + Section: Construction + */ + + // Public: Convert any range-compatible object to a {Range}. + // + // * `object` This can be an object that's already a {Range}, in which case it's + // simply returned, or an array containing two {Point}s or point-compatible + // arrays. + // * `copy` An optional boolean indicating whether to force the copying of objects + // that are already ranges. + // + // Returns: A {Range} based on the given object. + static fromObject(object, copy) { + if (Array.isArray(object)) { + return new (this)(object[0], object[1]); + } else if (object instanceof this) { + if (copy) { return object.copy(); } else { return object; } + } else { + return new (this)(object.start, object.end); + } + } + + // Returns a range based on an optional starting point and the given text. If + // no starting point is given it will be assumed to be [0, 0]. + // + // * `startPoint` (optional) {Point} where the range should start. + // * `text` A {String} after which the range should end. The range will have as many + // rows as the text has lines have an end column based on the length of the + // last line. + // + // Returns: A {Range} + static fromText(...args) { + let startPoint; + if (newlineRegex == null) { ({ + newlineRegex + } = require('./helpers')); } + + if (args.length > 1) { + startPoint = Point.fromObject(args.shift()); + } else { + startPoint = new Point(0, 0); + } + const text = args.shift(); + const endPoint = startPoint.copy(); + const lines = text.split(newlineRegex); + if (lines.length > 1) { + const lastIndex = lines.length - 1; + endPoint.row += lastIndex; + endPoint.column = lines[lastIndex].length; + } else { + endPoint.column += lines[0].length; + } + return new (this)(startPoint, endPoint); + } + + // Returns a {Range} that starts at the given point and ends at the + // start point plus the given row and column deltas. + // + // * `startPoint` A {Point} or point-compatible {Array} + // * `rowDelta` A {Number} indicating how many rows to add to the start point + // to get the end point. + // * `columnDelta` A {Number} indicating how many rows to columns to the start + // point to get the end point. + static fromPointWithDelta(startPoint, rowDelta, columnDelta) { + startPoint = Point.fromObject(startPoint); + const endPoint = new Point(startPoint.row + rowDelta, startPoint.column + columnDelta); + return new (this)(startPoint, endPoint); + } + + static fromPointWithTraversalExtent(startPoint, extent) { + startPoint = Point.fromObject(startPoint); + return new (this)(startPoint, startPoint.traverse(extent)); + } + + + /* + Section: Serialization and Deserialization + */ + + // Public: Call this with the result of {Range::serialize} to construct a new Range. + // + // * `array` {Array} of params to pass to the {::constructor} + static deserialize(array) { + if (Array.isArray(array)) { + return new (this)(array[0], array[1]); + } else { + return new (this)(); + } + } + + /* + Section: Construction + */ + + // Public: Construct a {Range} object + // + // * `pointA` {Point} or Point compatible {Array} (default: [0,0]) + // * `pointB` {Point} or Point compatible {Array} (default: [0,0]) + constructor(pointA, pointB) { + if (pointA == null) { pointA = new Point(0, 0); } + if (pointB == null) { pointB = new Point(0, 0); } + if (!(this instanceof Range)) { + return new Range(pointA, pointB); + } + + pointA = Point.fromObject(pointA); + pointB = Point.fromObject(pointB); + + if (pointA.isLessThanOrEqual(pointB)) { + this.start = pointA; + this.end = pointB; + } else { + this.start = pointB; + this.end = pointA; + } + } + + // Public: Returns a new range with the same start and end positions. + copy() { + return new this.constructor(this.start.copy(), this.end.copy()); + } + + // Public: Returns a new range with the start and end positions negated. + negate() { + return new this.constructor(this.start.negate(), this.end.negate()); + } + + /* + Section: Serialization and Deserialization + */ + + // Public: Returns a plain javascript object representation of the range. + serialize() { + return [this.start.serialize(), this.end.serialize()]; + } + + /* + Section: Range Details + */ + + // Public: Is the start position of this range equal to the end position? + // + // Returns a {Boolean}. + isEmpty() { + return this.start.isEqual(this.end); + } + + // Public: Returns a {Boolean} indicating whether this range starts and ends on + // the same row. + isSingleLine() { + return this.start.row === this.end.row; + } + + // Public: Get the number of rows in this range. + // + // Returns a {Number}. + getRowCount() { + return (this.end.row - this.start.row) + 1; + } + + // Public: Returns an array of all rows in the range. + getRows() { + return __range__(this.start.row, this.end.row, true); + } + + /* + Section: Operations + */ + + // Public: Freezes the range and its start and end point so it becomes + // immutable and returns itself. + // + // Returns an immutable version of this {Range} + freeze() { + this.start.freeze(); + this.end.freeze(); + return Object.freeze(this); + } + + // Public: Returns a new range that contains this range and the given range. + // + // * `otherRange` A {Range} or range-compatible {Array} + union(otherRange) { + const start = this.start.isLessThan(otherRange.start) ? this.start : otherRange.start; + const end = this.end.isGreaterThan(otherRange.end) ? this.end : otherRange.end; + return new this.constructor(start, end); + } + + // Public: Build and return a new range by translating this range's start and + // end points by the given delta(s). + // + // * `startDelta` A {Point} by which to translate the start of this range. + // * `endDelta` (optional) A {Point} to by which to translate the end of this + // range. If omitted, the `startDelta` will be used instead. + // + // Returns a {Range}. + translate(startDelta, endDelta) { + if (endDelta == null) { endDelta = startDelta; } + return new this.constructor(this.start.translate(startDelta), this.end.translate(endDelta)); + } + + // Public: Build and return a new range by traversing this range's start and + // end points by the given delta. + // + // See {Point::traverse} for details of how traversal differs from translation. + // + // * `delta` A {Point} containing the rows and columns to traverse to derive + // the new range. + // + // Returns a {Range}. + traverse(delta) { + return new this.constructor(this.start.traverse(delta), this.end.traverse(delta)); + } + + /* + Section: Comparison + */ + + // Public: Compare two Ranges + // + // * `otherRange` A {Range} or range-compatible {Array}. + // + // Returns `-1` if this range starts before the argument or contains it. + // Returns `0` if this range is equivalent to the argument. + // Returns `1` if this range starts after the argument or is contained by it. + compare(other) { + let value; + other = this.constructor.fromObject(other); + if ((value = this.start.compare(other.start))) { + return value; + } else { + return other.end.compare(this.end); + } + } + + // Public: Returns a {Boolean} indicating whether this range has the same start + // and end points as the given {Range} or range-compatible {Array}. + // + // * `otherRange` A {Range} or range-compatible {Array}. + isEqual(other) { + if (other == null) { return false; } + other = this.constructor.fromObject(other); + return other.start.isEqual(this.start) && other.end.isEqual(this.end); + } + + // Public: Returns a {Boolean} indicating whether this range starts and ends on + // the same row as the argument. + // + // * `otherRange` A {Range} or range-compatible {Array}. + coversSameRows(other) { + return (this.start.row === other.start.row) && (this.end.row === other.end.row); + } + + // Public: Determines whether this range intersects with the argument. + // + // * `otherRange` A {Range} or range-compatible {Array} + // * `exclusive` (optional) {Boolean} indicating whether to exclude endpoints + // when testing for intersection. Defaults to `false`. + // + // Returns a {Boolean}. + intersectsWith(otherRange, exclusive) { + if (exclusive) { + return !(this.end.isLessThanOrEqual(otherRange.start) || this.start.isGreaterThanOrEqual(otherRange.end)); + } else { + return !(this.end.isLessThan(otherRange.start) || this.start.isGreaterThan(otherRange.end)); + } + } + + // Public: Returns a {Boolean} indicating whether this range contains the given + // range. + // + // * `otherRange` A {Range} or range-compatible {Array} + // * `exclusive` (optional) {Boolean} including that the containment should be exclusive of + // endpoints. Defaults to false. + containsRange(otherRange, exclusive) { + const {start, end} = this.constructor.fromObject(otherRange); + return this.containsPoint(start, exclusive) && this.containsPoint(end, exclusive); + } + + // Public: Returns a {Boolean} indicating whether this range contains the given + // point. + // + // * `point` A {Point} or point-compatible {Array} + // * `exclusive` (optional) {Boolean} including that the containment should be exclusive of + // endpoints. Defaults to false. + containsPoint(point, exclusive) { + point = Point.fromObject(point); + if (exclusive) { + return point.isGreaterThan(this.start) && point.isLessThan(this.end); + } else { + return point.isGreaterThanOrEqual(this.start) && point.isLessThanOrEqual(this.end); + } + } + + // Public: Returns a {Boolean} indicating whether this range intersects the + // given row {Number}. + // + // * `row` Row {Number} + intersectsRow(row) { + return this.start.row <= row && row <= this.end.row; + } + + // Public: Returns a {Boolean} indicating whether this range intersects the + // row range indicated by the given startRow and endRow {Number}s. + // + // * `startRow` {Number} start row + // * `endRow` {Number} end row + intersectsRowRange(startRow, endRow) { + if (startRow > endRow) { [startRow, endRow] = [endRow, startRow]; } + return (this.end.row >= startRow) && (endRow >= this.start.row); + } + + getExtent() { + return this.end.traversalFrom(this.start); + } + + /* + Section: Conversion + */ + + toDelta() { + let columns; + const rows = this.end.row - this.start.row; + if (rows === 0) { + columns = this.end.column - this.start.column; + } else { + columns = this.end.column; + } + return new Point(rows, columns); + } + + // Public: Returns a string representation of the range. + toString() { + return `[${this.start} - ${this.end}]`; + } +} + +// ES5 classes differ from their predecessors in that you are not allowed to +// call them like ordinary functions. Hence we must write this wrapper function +// which delegates to `new Range` whether it was called with `new` or not. +function _Range (...args) { + return new Range(...args); +}; +_Range.displayName = 'Range'; +_Range.prototype = Range.prototype; +Object.assign(_Range.prototype, { + start: null, + end: null +}); +// Make the wrapper inherit the parent's static methods. +Object.setPrototypeOf(_Range, Range); + +module.exports = _Range; diff --git a/src/set-helpers.coffee b/src/set-helpers.coffee deleted file mode 100644 index e2f134f724..0000000000 --- a/src/set-helpers.coffee +++ /dev/null @@ -1,20 +0,0 @@ -setEqual = (a, b) -> - return false unless a.size is b.size - iterator = a.values() - until (next = iterator.next()).done - return false unless b.has(next.value) - true - -subtractSet = (set, valuesToRemove) -> - if set.size > valuesToRemove.size - valuesToRemove.forEach (value) -> set.delete(value) - else - set.forEach (value) -> set.delete(value) if valuesToRemove.has(value) - -addSet = (set, valuesToAdd) -> - valuesToAdd.forEach (value) -> set.add(value) - -intersectSet = (set, other) -> - set.forEach (value) -> set.delete(value) unless other.has(value) - -module.exports = {setEqual, subtractSet, addSet, intersectSet} diff --git a/src/set-helpers.js b/src/set-helpers.js new file mode 100644 index 0000000000..4424bf5331 --- /dev/null +++ b/src/set-helpers.js @@ -0,0 +1,39 @@ +function setEqual(a, b) { + let next; + if (a.size !== b.size) { return false; } + const iterator = a.values(); + while (!(next = iterator.next()).done) { + if (!b.has(next.value)) { return false; } + } + return true; +} + +function subtractSet(set, valuesToRemove) { + if (set.size > valuesToRemove.size) { + for (let value of valuesToRemove) { + set.delete(value); + } + } else { + for (let value of set) { + if (valuesToRemove.has(value)) { + set.delete(value); + } + } + } +} + +function addSet (set, valuesToAdd) { + for (let value of valuesToAdd) { + set.add(value); + } +} + +function intersectSet (set, other) { + for (let value of set) { + if (!other.has(value)) { + set.delete(value); + } + } +} + +module.exports = {setEqual, subtractSet, addSet, intersectSet}; diff --git a/src/text-buffer.js b/src/text-buffer.js index c6b39ccd9d..19a1bcbb21 100644 --- a/src/text-buffer.js +++ b/src/text-buffer.js @@ -1,12 +1,11 @@ const {Emitter, CompositeDisposable} = require('event-kit') -const {File} = require('pathwatcher') +const {File} = require('@pulsar-edit/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 {TextBuffer: NativeTextBuffer} = require('@pulsar-edit/superstring'); const Point = require('./point') const Range = require('./range') const DefaultHistoryProvider = require('./default-history-provider') @@ -78,14 +77,15 @@ class TextBuffer { this.conflict = false this.file = null this.fileSubscriptions = null + this.oldFileSubscriptions = 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.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.maxUndoEntries = params.maxUndoEntries ?? this.defaultMaxUndoEntries this.setHistoryProvider(new DefaultHistoryProvider(this)) this.languageMode = new NullLanguageMode() this.nextMarkerLayerId = 0 @@ -1360,7 +1360,10 @@ class TextBuffer { // // Returns a checkpoint id value. createCheckpoint (options) { - return this.historyProvider.createCheckpoint({markers: this.createMarkerSnapshot(options != null ? options.selectionsMarkerLayer : undefined), isBarrier: false}) + return this.historyProvider.createCheckpoint({ + markers: this.createMarkerSnapshot(options?.selectionsMarkerLayer ?? undefined), + isBarrier: false + }) } // Public: Revert the buffer to the state it was in when the given @@ -2113,20 +2116,22 @@ class TextBuffer { encoding: this.getEncoding(), force: options && options.discardChanges, patch: this.loaded - }, - (percentDone, patch) => { - if (this.loadCount > loadCount) return false - if (patch) { - if (patch.getChangeCount() > 0) { - checkpoint = this.historyProvider.createCheckpoint({markers: this.createMarkerSnapshot(), isBarrier: true}) - this.emitter.emit('will-reload') - return this.emitWillChangeEvent() - } else if (options && options.discardChanges) { - return this.emitter.emit('will-reload') - } - } } ) + + // If this is not the most recent load of this file, then we should bow + // out and let the newer call to `load` handle the tasks below. + if (this.loadCount > loadCount) return + + if (patch) { + if (patch.getChangeCount() > 0) { + checkpoint = this.historyProvider.createCheckpoint({markers: this.createMarkerSnapshot(), isBarrier: true}) + this.emitter.emit('will-reload') + this.emitWillChangeEvent() + } else if (options && options.discardChanges) { + this.emitter.emit('will-reload') + } + } this.finishLoading(checkpoint, patch, options) } catch (error) { if ((!options || !options.mustExist) && error.code === 'ENOENT') { @@ -2207,9 +2212,8 @@ class TextBuffer { this.emitter.emit('did-destroy') this.emitter.clear() - if (this.fileSubscriptions != null) { - this.fileSubscriptions.dispose() - } + this.fileSubscriptions?.dispose() + for (const id in this.markerLayers) { const markerLayer = this.markerLayers[id] markerLayer.destroy() @@ -2248,7 +2252,14 @@ class TextBuffer { } subscribeToFile () { - if (this.fileSubscriptions) this.fileSubscriptions.dispose() + if (this.fileSubscriptions) { + // If we were to unsubscribe and immediately resubscribe, we might + // trigger destruction and recreation of a native file watcher — which is + // costly and unnecessary. We can avoid that cost by overlapping the + // switch and only disposing of the old `CompositeDisposable` after the + // new one has attached its subscriptions. + this.oldFileSubscriptions = this.fileSubscriptions + } this.fileSubscriptions = new CompositeDisposable() if (this.file.onDidChange) { @@ -2260,9 +2271,7 @@ class TextBuffer { this.fileHasChangedSinceLastLoad = true if (this.isModified()) { - const source = this.file instanceof File - ? this.file.getPath() - : this.file.createReadStream() + const source = this.file.getPath() if (!(await this.buffer.baseTextMatchesFile(source, this.getEncoding()))) { this.emitter.emit('did-conflict') } @@ -2295,6 +2304,11 @@ class TextBuffer { this.emitter.emit('will-throw-watch-error', error) })) } + + if (this.oldFileSubscriptions) { + this.oldFileSubscriptions.dispose() + this.oldFileSubscriptions = null + } } createMarkerSnapshot (selectionsMarkerLayer) { @@ -2491,8 +2505,6 @@ Object.assign(TextBuffer, { spliceArray: spliceArray }) -TextBuffer.Patch = superstring.Patch - Object.assign(TextBuffer.prototype, { stoppedChangingDelay: 300, fileChangeDelay: 200, @@ -2518,7 +2530,7 @@ class ChangeEvent { enumerable: false, get () { if (oldText == null) { - const oldBuffer = new NativeTextBuffer(this.newText) + const oldBuffer = new NativeTextBuffer(this.newText || "") for (let i = changes.length - 1; i >= 0; i--) { const change = changes[i] oldBuffer.setTextInRange(