diff --git a/apm/package.json b/apm/package.json index e8d4321b1..5391c9972 100644 --- a/apm/package.json +++ b/apm/package.json @@ -6,6 +6,6 @@ "url": "https://github.com/atom/atom.git" }, "dependencies": { - "atom-package-manager": "1.18.7" + "atom-package-manager": "1.18.8" } } diff --git a/package.json b/package.json index ffc655280..418fa0b75 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "atom", "productName": "Atom", - "version": "1.22.0-dev", + "version": "1.23.0-dev", "description": "A hackable text editor for the 21st Century.", "main": "./src/main-process/main.js", "repository": { @@ -12,11 +12,11 @@ "url": "https://github.com/atom/atom/issues" }, "license": "MIT", - "electronVersion": "1.6.9", + "electronVersion": "1.6.14", "dependencies": { "@atom/source-map-support": "^0.3.4", "async": "0.2.6", - "atom-keymap": "8.2.5", + "atom-keymap": "8.2.6", "atom-select-list": "^0.1.0", "atom-ui": "0.4.1", "babel-core": "5.8.38", @@ -31,13 +31,13 @@ "etch": "^0.12.6", "event-kit": "^2.4.0", "find-parent-dir": "^0.3.0", - "first-mate": "7.0.7", + "first-mate": "7.0.9", "focus-trap": "^2.3.0", "fs-admin": "^0.1.6", "fs-plus": "^3.0.1", "fstream": "0.1.24", "fuzzaldrin": "^2.1", - "git-utils": "5.0.0", + "git-utils": "5.1.0", "glob": "^7.1.1", "grim": "1.5.0", "jasmine-json": "~0.0", @@ -70,7 +70,7 @@ "service-hub": "^0.7.4", "sinon": "1.17.4", "temp": "^0.8.3", - "text-buffer": "13.3.4", + "text-buffer": "13.5.2", "typescript-simple": "1.0.0", "underscore-plus": "^1.6.6", "winreg": "^1.2.1", @@ -90,14 +90,14 @@ "solarized-dark-syntax": "1.1.2", "solarized-light-syntax": "1.1.2", "about": "1.7.8", - "archive-view": "0.63.3", + "archive-view": "0.63.4", "autocomplete-atom-api": "0.10.3", "autocomplete-css": "0.17.3", "autocomplete-html": "0.8.2", - "autocomplete-plus": "2.35.10", + "autocomplete-plus": "2.36.2", "autocomplete-snippets": "1.11.1", "autoflow": "0.29.0", - "autosave": "0.24.4", + "autosave": "0.24.6", "background-tips": "0.27.1", "bookmarks": "0.44.4", "bracket-matcher": "0.88.0", @@ -105,20 +105,20 @@ "dalek": "0.2.1", "deprecation-cop": "0.56.9", "dev-live-reload": "0.47.1", - "encoding-selector": "0.23.6", + "encoding-selector": "0.23.7", "exception-reporting": "0.41.4", "find-and-replace": "0.212.3", - "fuzzy-finder": "1.6.0", - "github": "0.6.2", + "fuzzy-finder": "1.6.1", + "github": "0.6.3", "git-diff": "1.3.6", "go-to-line": "0.32.1", "grammar-selector": "0.49.6", - "image-view": "0.62.3", + "image-view": "0.62.4", "incompatible-packages": "0.27.3", "keybinding-resolver": "0.38.0", "line-ending-selector": "0.7.4", "link": "0.31.3", - "markdown-preview": "0.159.13", + "markdown-preview": "0.159.14", "metrics": "1.2.6", "notifications": "0.69.2", "open-on-github": "1.2.1", @@ -128,8 +128,8 @@ "spell-check": "0.72.2", "status-bar": "1.8.13", "styleguide": "0.49.7", - "symbols-view": "0.118.0", - "tabs": "0.107.3", + "symbols-view": "0.118.1", + "tabs": "0.107.4", "timecop": "0.36.0", "tree-view": "0.218.0", "update-package-dependencies": "0.12.0", @@ -139,22 +139,22 @@ "language-c": "0.58.1", "language-clojure": "0.22.4", "language-coffee-script": "0.49.1", - "language-csharp": "0.14.2", + "language-csharp": "0.14.3", "language-css": "0.42.6", "language-gfm": "0.90.1", "language-git": "0.19.1", "language-go": "0.44.2", - "language-html": "0.48.0", + "language-html": "0.48.1", "language-hyperlink": "0.16.2", "language-java": "0.27.4", "language-javascript": "0.127.5", "language-json": "0.19.1", "language-less": "0.33.0", "language-make": "0.22.3", - "language-mustache": "0.14.2", + "language-mustache": "0.14.3", "language-objective-c": "0.15.1", "language-perl": "0.37.0", - "language-php": "0.42.0", + "language-php": "0.42.1", "language-property-list": "0.9.1", "language-python": "0.45.4", "language-ruby": "0.71.3", diff --git a/resources/app-icons/beta/atom.icns b/resources/app-icons/beta/atom.icns index 69abe031e..6737fa27f 100644 Binary files a/resources/app-icons/beta/atom.icns and b/resources/app-icons/beta/atom.icns differ diff --git a/resources/app-icons/dev/atom.icns b/resources/app-icons/dev/atom.icns index 84319be3d..b006a1a83 100644 Binary files a/resources/app-icons/dev/atom.icns and b/resources/app-icons/dev/atom.icns differ diff --git a/resources/app-icons/stable/atom.icns b/resources/app-icons/stable/atom.icns index 2f3246bb8..73ef96330 100644 Binary files a/resources/app-icons/stable/atom.icns and b/resources/app-icons/stable/atom.icns differ diff --git a/script/lib/generate-startup-snapshot.js b/script/lib/generate-startup-snapshot.js index 7701b6a34..2905bca1b 100644 --- a/script/lib/generate-startup-snapshot.js +++ b/script/lib/generate-startup-snapshot.js @@ -39,7 +39,7 @@ module.exports = function (packagedAppPath) { relativePath === path.join('..', 'node_modules', 'debug', 'node.js') || relativePath === path.join('..', 'node_modules', 'fs-extra', 'lib', 'index.js') || relativePath === path.join('..', 'node_modules', 'github', 'node_modules', 'fs-extra', 'lib', 'index.js') || - relativePath === path.join('..', 'node_modules', 'git-utils', 'lib', 'git.js') || + relativePath === path.join('..', 'node_modules', 'git-utils', 'src', 'git.js') || relativePath === path.join('..', 'node_modules', 'glob', 'glob.js') || relativePath === path.join('..', 'node_modules', 'graceful-fs', 'graceful-fs.js') || relativePath === path.join('..', 'node_modules', 'htmlparser2', 'lib', 'index.js') || diff --git a/spec/git-repository-provider-spec.coffee b/spec/git-repository-provider-spec.coffee deleted file mode 100644 index 16ccf8938..000000000 --- a/spec/git-repository-provider-spec.coffee +++ /dev/null @@ -1,98 +0,0 @@ -path = require 'path' -fs = require 'fs-plus' -temp = require('temp').track() -{Directory} = require 'pathwatcher' -GitRepository = require '../src/git-repository' -GitRepositoryProvider = require '../src/git-repository-provider' - -describe "GitRepositoryProvider", -> - provider = null - - beforeEach -> - provider = new GitRepositoryProvider(atom.project, atom.config, atom.confirm) - - afterEach -> - try - temp.cleanupSync() - - describe ".repositoryForDirectory(directory)", -> - describe "when specified a Directory with a Git repository", -> - it "returns a Promise that resolves to a GitRepository", -> - waitsForPromise -> - directory = new Directory path.join(__dirname, 'fixtures', 'git', 'master.git') - provider.repositoryForDirectory(directory).then (result) -> - expect(result).toBeInstanceOf GitRepository - expect(provider.pathToRepository[result.getPath()]).toBeTruthy() - expect(result.statusTask).toBeTruthy() - expect(result.getType()).toBe 'git' - - it "returns the same GitRepository for different Directory objects in the same repo", -> - firstRepo = null - secondRepo = null - - waitsForPromise -> - directory = new Directory path.join(__dirname, 'fixtures', 'git', 'master.git') - provider.repositoryForDirectory(directory).then (result) -> firstRepo = result - - waitsForPromise -> - directory = new Directory path.join(__dirname, 'fixtures', 'git', 'master.git', 'objects') - provider.repositoryForDirectory(directory).then (result) -> secondRepo = result - - runs -> - expect(firstRepo).toBeInstanceOf GitRepository - expect(firstRepo).toBe secondRepo - - describe "when specified a Directory without a Git repository", -> - it "returns a Promise that resolves to null", -> - waitsForPromise -> - directory = new Directory temp.mkdirSync('dir') - provider.repositoryForDirectory(directory).then (result) -> - expect(result).toBe null - - describe "when specified a Directory with an invalid Git repository", -> - it "returns a Promise that resolves to null", -> - waitsForPromise -> - dirPath = temp.mkdirSync('dir') - fs.writeFileSync(path.join(dirPath, '.git', 'objects'), '') - fs.writeFileSync(path.join(dirPath, '.git', 'HEAD'), '') - fs.writeFileSync(path.join(dirPath, '.git', 'refs'), '') - - directory = new Directory dirPath - provider.repositoryForDirectory(directory).then (result) -> - expect(result).toBe null - - describe "when specified a Directory with a valid gitfile-linked repository", -> - it "returns a Promise that resolves to a GitRepository", -> - waitsForPromise -> - gitDirPath = path.join(__dirname, 'fixtures', 'git', 'master.git') - workDirPath = temp.mkdirSync('git-workdir') - fs.writeFileSync(path.join(workDirPath, '.git'), 'gitdir: ' + gitDirPath+'\n') - - directory = new Directory workDirPath - provider.repositoryForDirectory(directory).then (result) -> - expect(result).toBeInstanceOf GitRepository - expect(provider.pathToRepository[result.getPath()]).toBeTruthy() - expect(result.statusTask).toBeTruthy() - expect(result.getType()).toBe 'git' - - describe "when specified a Directory without existsSync()", -> - directory = null - provider = null - beforeEach -> - # An implementation of Directory that does not implement existsSync(). - subdirectory = {} - directory = - getSubdirectory: -> - isRoot: -> true - spyOn(directory, "getSubdirectory").andReturn(subdirectory) - - it "returns null", -> - repo = provider.repositoryForDirectorySync(directory) - expect(repo).toBe null - expect(directory.getSubdirectory).toHaveBeenCalledWith(".git") - - it "returns a Promise that resolves to null for the async implementation", -> - waitsForPromise -> - provider.repositoryForDirectory(directory).then (repo) -> - expect(repo).toBe null - expect(directory.getSubdirectory).toHaveBeenCalledWith(".git") diff --git a/spec/git-repository-provider-spec.js b/spec/git-repository-provider-spec.js new file mode 100644 index 000000000..e1d0168a9 --- /dev/null +++ b/spec/git-repository-provider-spec.js @@ -0,0 +1,103 @@ +const path = require('path') +const fs = require('fs-plus') +const temp = require('temp').track() +const {Directory} = require('pathwatcher') +const GitRepository = require('../src/git-repository') +const GitRepositoryProvider = require('../src/git-repository-provider') +const {it, fit, ffit, fffit, beforeEach} = require('./async-spec-helpers') + +describe('GitRepositoryProvider', () => { + let provider + + beforeEach(() => { + provider = new GitRepositoryProvider(atom.project, atom.config, atom.confirm) + }) + + describe('.repositoryForDirectory(directory)', () => { + describe('when specified a Directory with a Git repository', () => { + it('resolves with a GitRepository', async () => { + const directory = new Directory(path.join(__dirname, 'fixtures', 'git', 'master.git')) + const result = await provider.repositoryForDirectory(directory) + expect(result).toBeInstanceOf(GitRepository) + expect(provider.pathToRepository[result.getPath()]).toBeTruthy() + expect(result.getType()).toBe('git') + + // Refresh should be started + await new Promise(resolve => result.onDidChangeStatuses(resolve)) + }) + + it('resolves with the same GitRepository for different Directory objects in the same repo', async () => { + const firstRepo = await provider.repositoryForDirectory( + new Directory(path.join(__dirname, 'fixtures', 'git', 'master.git')) + ) + const secondRepo = await provider.repositoryForDirectory( + new Directory(path.join(__dirname, 'fixtures', 'git', 'master.git', 'objects')) + ) + + expect(firstRepo).toBeInstanceOf(GitRepository) + expect(firstRepo).toBe(secondRepo) + }) + }) + + describe('when specified a Directory without a Git repository', () => { + it('resolves with null', async () => { + const directory = new Directory(temp.mkdirSync('dir')) + const repo = await provider.repositoryForDirectory(directory) + expect(repo).toBe(null) + }) + }) + + describe('when specified a Directory with an invalid Git repository', () => { + it('resolves with null', async () => { + const dirPath = temp.mkdirSync('dir') + fs.writeFileSync(path.join(dirPath, '.git', 'objects'), '') + fs.writeFileSync(path.join(dirPath, '.git', 'HEAD'), '') + fs.writeFileSync(path.join(dirPath, '.git', 'refs'), '') + + const directory = new Directory(dirPath) + const repo = await provider.repositoryForDirectory(directory) + expect(repo).toBe(null) + }) + }) + + describe('when specified a Directory with a valid gitfile-linked repository', () => { + it('returns a Promise that resolves to a GitRepository', async () => { + const gitDirPath = path.join(__dirname, 'fixtures', 'git', 'master.git') + const workDirPath = temp.mkdirSync('git-workdir') + fs.writeFileSync(path.join(workDirPath, '.git'), `gitdir: ${gitDirPath}\n`) + + const directory = new Directory(workDirPath) + const result = await provider.repositoryForDirectory(directory) + expect(result).toBeInstanceOf(GitRepository) + expect(provider.pathToRepository[result.getPath()]).toBeTruthy() + expect(result.getType()).toBe('git') + }) + }) + + describe('when specified a Directory without existsSync()', () => { + let directory + + beforeEach(() => { + // An implementation of Directory that does not implement existsSync(). + const subdirectory = {} + directory = { + getSubdirectory () {}, + isRoot () { return true } + } + spyOn(directory, 'getSubdirectory').andReturn(subdirectory) + }) + + it('returns null', () => { + const repo = provider.repositoryForDirectorySync(directory) + expect(repo).toBe(null) + expect(directory.getSubdirectory).toHaveBeenCalledWith('.git') + }) + + it('returns a Promise that resolves to null for the async implementation', async () => { + const repo = await provider.repositoryForDirectory(directory) + expect(repo).toBe(null) + expect(directory.getSubdirectory).toHaveBeenCalledWith('.git') + }) + }) + }) +}) diff --git a/spec/git-repository-spec.coffee b/spec/git-repository-spec.coffee index 47ca84580..e4d1e0c7f 100644 --- a/spec/git-repository-spec.coffee +++ b/spec/git-repository-spec.coffee @@ -283,11 +283,15 @@ describe "GitRepository", -> [editor] = [] beforeEach -> + statusRefreshed = false atom.project.setPaths([copyRepository()]) + atom.project.getRepositories()[0].onDidChangeStatuses -> statusRefreshed = true waitsForPromise -> atom.workspace.open('other.txt').then (o) -> editor = o + waitsFor 'repo to refresh', -> statusRefreshed + it "emits a status-changed event when a buffer is saved", -> editor.insertNewline() diff --git a/spec/language-mode-spec.coffee b/spec/language-mode-spec.coffee deleted file mode 100644 index 68d0f7b09..000000000 --- a/spec/language-mode-spec.coffee +++ /dev/null @@ -1,506 +0,0 @@ -describe "LanguageMode", -> - [editor, buffer, languageMode] = [] - - afterEach -> - editor.destroy() - - describe "javascript", -> - beforeEach -> - waitsForPromise -> - atom.workspace.open('sample.js', autoIndent: false).then (o) -> - editor = o - {buffer, languageMode} = editor - - waitsForPromise -> - atom.packages.activatePackage('language-javascript') - - afterEach -> - waitsForPromise -> - atom.packages.deactivatePackages() - runs -> - atom.packages.unloadPackages() - - describe ".minIndentLevelForRowRange(startRow, endRow)", -> - it "returns the minimum indent level for the given row range", -> - expect(languageMode.minIndentLevelForRowRange(4, 7)).toBe 2 - expect(languageMode.minIndentLevelForRowRange(5, 7)).toBe 2 - expect(languageMode.minIndentLevelForRowRange(5, 6)).toBe 3 - expect(languageMode.minIndentLevelForRowRange(9, 11)).toBe 1 - expect(languageMode.minIndentLevelForRowRange(10, 10)).toBe 0 - - describe ".toggleLineCommentsForBufferRows(start, end)", -> - it "comments/uncomments lines in the given range", -> - languageMode.toggleLineCommentsForBufferRows(4, 7) - expect(buffer.lineForRow(4)).toBe " // while(items.length > 0) {" - expect(buffer.lineForRow(5)).toBe " // current = items.shift();" - expect(buffer.lineForRow(6)).toBe " // current < pivot ? left.push(current) : right.push(current);" - expect(buffer.lineForRow(7)).toBe " // }" - - languageMode.toggleLineCommentsForBufferRows(4, 5) - expect(buffer.lineForRow(4)).toBe " while(items.length > 0) {" - expect(buffer.lineForRow(5)).toBe " current = items.shift();" - expect(buffer.lineForRow(6)).toBe " // current < pivot ? left.push(current) : right.push(current);" - expect(buffer.lineForRow(7)).toBe " // }" - - buffer.setText('\tvar i;') - languageMode.toggleLineCommentsForBufferRows(0, 0) - expect(buffer.lineForRow(0)).toBe "\t// var i;" - - buffer.setText('var i;') - languageMode.toggleLineCommentsForBufferRows(0, 0) - expect(buffer.lineForRow(0)).toBe "// var i;" - - buffer.setText(' var i;') - languageMode.toggleLineCommentsForBufferRows(0, 0) - expect(buffer.lineForRow(0)).toBe " // var i;" - - buffer.setText(' ') - languageMode.toggleLineCommentsForBufferRows(0, 0) - expect(buffer.lineForRow(0)).toBe " // " - - buffer.setText(' a\n \n b') - languageMode.toggleLineCommentsForBufferRows(0, 2) - expect(buffer.lineForRow(0)).toBe " // a" - expect(buffer.lineForRow(1)).toBe " // " - expect(buffer.lineForRow(2)).toBe " // b" - - buffer.setText(' \n // var i;') - languageMode.toggleLineCommentsForBufferRows(0, 1) - expect(buffer.lineForRow(0)).toBe ' ' - expect(buffer.lineForRow(1)).toBe ' var i;' - - describe ".rowRangeForCodeFoldAtBufferRow(bufferRow)", -> - it "returns the start/end rows of the foldable region starting at the given row", -> - expect(languageMode.rowRangeForCodeFoldAtBufferRow(0)).toEqual [0, 12] - expect(languageMode.rowRangeForCodeFoldAtBufferRow(1)).toEqual [1, 9] - expect(languageMode.rowRangeForCodeFoldAtBufferRow(2)).toBeNull() - expect(languageMode.rowRangeForCodeFoldAtBufferRow(4)).toEqual [4, 7] - - describe ".rowRangeForCommentAtBufferRow(bufferRow)", -> - it "returns the start/end rows of the foldable comment starting at the given row", -> - buffer.setText("//this is a multi line comment\n//another line") - expect(languageMode.rowRangeForCommentAtBufferRow(0)).toEqual [0, 1] - expect(languageMode.rowRangeForCommentAtBufferRow(1)).toEqual [0, 1] - - buffer.setText("//this is a multi line comment\n//another line\n//and one more") - expect(languageMode.rowRangeForCommentAtBufferRow(0)).toEqual [0, 2] - expect(languageMode.rowRangeForCommentAtBufferRow(1)).toEqual [0, 2] - - buffer.setText("//this is a multi line comment\n\n//with an empty line") - expect(languageMode.rowRangeForCommentAtBufferRow(0)).toBeUndefined() - expect(languageMode.rowRangeForCommentAtBufferRow(1)).toBeUndefined() - expect(languageMode.rowRangeForCommentAtBufferRow(2)).toBeUndefined() - - buffer.setText("//this is a single line comment\n") - expect(languageMode.rowRangeForCommentAtBufferRow(0)).toBeUndefined() - expect(languageMode.rowRangeForCommentAtBufferRow(1)).toBeUndefined() - - buffer.setText("//this is a single line comment") - expect(languageMode.rowRangeForCommentAtBufferRow(0)).toBeUndefined() - - describe ".suggestedIndentForBufferRow", -> - it "bases indentation off of the previous non-blank line", -> - expect(languageMode.suggestedIndentForBufferRow(0)).toBe 0 - expect(languageMode.suggestedIndentForBufferRow(1)).toBe 1 - expect(languageMode.suggestedIndentForBufferRow(2)).toBe 2 - expect(languageMode.suggestedIndentForBufferRow(5)).toBe 3 - expect(languageMode.suggestedIndentForBufferRow(7)).toBe 2 - expect(languageMode.suggestedIndentForBufferRow(9)).toBe 1 - expect(languageMode.suggestedIndentForBufferRow(11)).toBe 1 - - it "does not take invisibles into account", -> - editor.update({showInvisibles: true}) - expect(languageMode.suggestedIndentForBufferRow(0)).toBe 0 - expect(languageMode.suggestedIndentForBufferRow(1)).toBe 1 - expect(languageMode.suggestedIndentForBufferRow(2)).toBe 2 - expect(languageMode.suggestedIndentForBufferRow(5)).toBe 3 - expect(languageMode.suggestedIndentForBufferRow(7)).toBe 2 - expect(languageMode.suggestedIndentForBufferRow(9)).toBe 1 - expect(languageMode.suggestedIndentForBufferRow(11)).toBe 1 - - describe "rowRangeForParagraphAtBufferRow", -> - describe "with code and comments", -> - beforeEach -> - buffer.setText ''' - var quicksort = function () { - /* Single line comment block */ - var sort = function(items) {}; - - /* - A multiline - comment is here - */ - var sort = function(items) {}; - - // A comment - // - // Multiple comment - // lines - var sort = function(items) {}; - // comment line after fn - - var nosort = function(items) { - return item; - } - - }; - ''' - - it "will limit paragraph range to comments", -> - range = languageMode.rowRangeForParagraphAtBufferRow(0) - expect(range).toEqual [[0, 0], [0, 29]] - - range = languageMode.rowRangeForParagraphAtBufferRow(10) - expect(range).toEqual [[10, 0], [10, 14]] - range = languageMode.rowRangeForParagraphAtBufferRow(11) - expect(range).toBeFalsy() - range = languageMode.rowRangeForParagraphAtBufferRow(12) - expect(range).toEqual [[12, 0], [13, 10]] - - range = languageMode.rowRangeForParagraphAtBufferRow(14) - expect(range).toEqual [[14, 0], [14, 32]] - - range = languageMode.rowRangeForParagraphAtBufferRow(15) - expect(range).toEqual [[15, 0], [15, 26]] - - range = languageMode.rowRangeForParagraphAtBufferRow(18) - expect(range).toEqual [[17, 0], [19, 3]] - - describe "coffeescript", -> - beforeEach -> - waitsForPromise -> - atom.workspace.open('coffee.coffee', autoIndent: false).then (o) -> - editor = o - {buffer, languageMode} = editor - - waitsForPromise -> - atom.packages.activatePackage('language-coffee-script') - - afterEach -> - waitsForPromise -> - atom.packages.deactivatePackages() - runs -> - atom.packages.unloadPackages() - - describe ".toggleLineCommentsForBufferRows(start, end)", -> - it "comments/uncomments lines in the given range", -> - languageMode.toggleLineCommentsForBufferRows(4, 6) - expect(buffer.lineForRow(4)).toBe " # pivot = items.shift()" - expect(buffer.lineForRow(5)).toBe " # left = []" - expect(buffer.lineForRow(6)).toBe " # right = []" - - languageMode.toggleLineCommentsForBufferRows(4, 5) - expect(buffer.lineForRow(4)).toBe " pivot = items.shift()" - expect(buffer.lineForRow(5)).toBe " left = []" - expect(buffer.lineForRow(6)).toBe " # right = []" - - it "comments/uncomments lines when empty line", -> - languageMode.toggleLineCommentsForBufferRows(4, 7) - expect(buffer.lineForRow(4)).toBe " # pivot = items.shift()" - expect(buffer.lineForRow(5)).toBe " # left = []" - expect(buffer.lineForRow(6)).toBe " # right = []" - expect(buffer.lineForRow(7)).toBe " # " - - languageMode.toggleLineCommentsForBufferRows(4, 5) - expect(buffer.lineForRow(4)).toBe " pivot = items.shift()" - expect(buffer.lineForRow(5)).toBe " left = []" - expect(buffer.lineForRow(6)).toBe " # right = []" - expect(buffer.lineForRow(7)).toBe " # " - - describe "fold suggestion", -> - describe ".rowRangeForCodeFoldAtBufferRow(bufferRow)", -> - it "returns the start/end rows of the foldable region starting at the given row", -> - expect(languageMode.rowRangeForCodeFoldAtBufferRow(0)).toEqual [0, 20] - expect(languageMode.rowRangeForCodeFoldAtBufferRow(1)).toEqual [1, 17] - expect(languageMode.rowRangeForCodeFoldAtBufferRow(2)).toBeNull() - expect(languageMode.rowRangeForCodeFoldAtBufferRow(19)).toEqual [19, 20] - - describe "css", -> - beforeEach -> - waitsForPromise -> - atom.workspace.open('css.css', autoIndent: false).then (o) -> - editor = o - {buffer, languageMode} = editor - - waitsForPromise -> - atom.packages.activatePackage('language-css') - - afterEach -> - waitsForPromise -> - atom.packages.deactivatePackages() - runs -> - atom.packages.unloadPackages() - - describe ".toggleLineCommentsForBufferRows(start, end)", -> - it "comments/uncomments lines in the given range", -> - languageMode.toggleLineCommentsForBufferRows(0, 1) - expect(buffer.lineForRow(0)).toBe "/*body {" - expect(buffer.lineForRow(1)).toBe " font-size: 1234px;*/" - expect(buffer.lineForRow(2)).toBe " width: 110%;" - expect(buffer.lineForRow(3)).toBe " font-weight: bold !important;" - - languageMode.toggleLineCommentsForBufferRows(2, 2) - expect(buffer.lineForRow(0)).toBe "/*body {" - expect(buffer.lineForRow(1)).toBe " font-size: 1234px;*/" - expect(buffer.lineForRow(2)).toBe " /*width: 110%;*/" - expect(buffer.lineForRow(3)).toBe " font-weight: bold !important;" - - languageMode.toggleLineCommentsForBufferRows(0, 1) - expect(buffer.lineForRow(0)).toBe "body {" - expect(buffer.lineForRow(1)).toBe " font-size: 1234px;" - expect(buffer.lineForRow(2)).toBe " /*width: 110%;*/" - expect(buffer.lineForRow(3)).toBe " font-weight: bold !important;" - - it "uncomments lines with leading whitespace", -> - buffer.setTextInRange([[2, 0], [2, Infinity]], " /*width: 110%;*/") - languageMode.toggleLineCommentsForBufferRows(2, 2) - expect(buffer.lineForRow(2)).toBe " width: 110%;" - - it "uncomments lines with trailing whitespace", -> - buffer.setTextInRange([[2, 0], [2, Infinity]], "/*width: 110%;*/ ") - languageMode.toggleLineCommentsForBufferRows(2, 2) - expect(buffer.lineForRow(2)).toBe "width: 110%; " - - it "uncomments lines with leading and trailing whitespace", -> - buffer.setTextInRange([[2, 0], [2, Infinity]], " /*width: 110%;*/ ") - languageMode.toggleLineCommentsForBufferRows(2, 2) - expect(buffer.lineForRow(2)).toBe " width: 110%; " - - describe "less", -> - beforeEach -> - waitsForPromise -> - atom.workspace.open('sample.less', autoIndent: false).then (o) -> - editor = o - {buffer, languageMode} = editor - - waitsForPromise -> - atom.packages.activatePackage('language-less') - - waitsForPromise -> - atom.packages.activatePackage('language-css') - - afterEach -> - waitsForPromise -> - atom.packages.deactivatePackages() - runs -> - atom.packages.unloadPackages() - - describe "when commenting lines", -> - it "only uses the `commentEnd` pattern if it comes from the same grammar as the `commentStart`", -> - languageMode.toggleLineCommentsForBufferRows(0, 0) - expect(buffer.lineForRow(0)).toBe "// @color: #4D926F;" - - describe "xml", -> - beforeEach -> - waitsForPromise -> - atom.workspace.open('sample.xml', autoIndent: false).then (o) -> - editor = o - editor.setText("") - {buffer, languageMode} = editor - - waitsForPromise -> - atom.packages.activatePackage('language-xml') - - afterEach -> - waitsForPromise -> - atom.packages.deactivatePackages() - runs -> - atom.packages.unloadPackages() - - describe "when uncommenting lines", -> - it "removes the leading whitespace from the comment end pattern match", -> - languageMode.toggleLineCommentsForBufferRows(0, 0) - expect(buffer.lineForRow(0)).toBe "test" - - describe "folding", -> - beforeEach -> - waitsForPromise -> - atom.workspace.open('sample.js', autoIndent: false).then (o) -> - editor = o - {buffer, languageMode} = editor - - waitsForPromise -> - atom.packages.activatePackage('language-javascript') - - afterEach -> - waitsForPromise -> - atom.packages.deactivatePackages() - runs -> - atom.packages.unloadPackages() - - it "maintains cursor buffer position when a folding/unfolding", -> - editor.setCursorBufferPosition([5, 5]) - languageMode.foldAll() - expect(editor.getCursorBufferPosition()).toEqual([5, 5]) - - describe ".unfoldAll()", -> - it "unfolds every folded line", -> - initialScreenLineCount = editor.getScreenLineCount() - languageMode.foldBufferRow(0) - languageMode.foldBufferRow(1) - expect(editor.getScreenLineCount()).toBeLessThan initialScreenLineCount - languageMode.unfoldAll() - expect(editor.getScreenLineCount()).toBe initialScreenLineCount - - describe ".foldAll()", -> - it "folds every foldable line", -> - languageMode.foldAll() - - [fold1, fold2, fold3] = languageMode.unfoldAll() - expect([fold1.start.row, fold1.end.row]).toEqual [0, 12] - expect([fold2.start.row, fold2.end.row]).toEqual [1, 9] - expect([fold3.start.row, fold3.end.row]).toEqual [4, 7] - - describe ".foldBufferRow(bufferRow)", -> - describe "when bufferRow can be folded", -> - it "creates a fold based on the syntactic region starting at the given row", -> - languageMode.foldBufferRow(1) - [fold] = languageMode.unfoldAll() - expect([fold.start.row, fold.end.row]).toEqual [1, 9] - - describe "when bufferRow can't be folded", -> - it "searches upward for the first row that begins a syntactic region containing the given buffer row (and folds it)", -> - languageMode.foldBufferRow(8) - [fold] = languageMode.unfoldAll() - expect([fold.start.row, fold.end.row]).toEqual [1, 9] - - describe "when the bufferRow is already folded", -> - it "searches upward for the first row that begins a syntactic region containing the folded row (and folds it)", -> - languageMode.foldBufferRow(2) - expect(editor.isFoldedAtBufferRow(0)).toBe(false) - expect(editor.isFoldedAtBufferRow(1)).toBe(true) - - languageMode.foldBufferRow(1) - expect(editor.isFoldedAtBufferRow(0)).toBe(true) - - describe "when the bufferRow is in a multi-line comment", -> - it "searches upward and downward for surrounding comment lines and folds them as a single fold", -> - buffer.insert([1, 0], " //this is a comment\n // and\n //more docs\n\n//second comment") - languageMode.foldBufferRow(1) - [fold] = languageMode.unfoldAll() - expect([fold.start.row, fold.end.row]).toEqual [1, 3] - - describe "when the bufferRow is a single-line comment", -> - it "searches upward for the first row that begins a syntactic region containing the folded row (and folds it)", -> - buffer.insert([1, 0], " //this is a single line comment\n") - languageMode.foldBufferRow(1) - [fold] = languageMode.unfoldAll() - expect([fold.start.row, fold.end.row]).toEqual [0, 13] - - describe ".foldAllAtIndentLevel(indentLevel)", -> - it "folds blocks of text at the given indentation level", -> - languageMode.foldAllAtIndentLevel(0) - expect(editor.lineTextForScreenRow(0)).toBe "var quicksort = function () {" + editor.displayLayer.foldCharacter - expect(editor.getLastScreenRow()).toBe 0 - - languageMode.foldAllAtIndentLevel(1) - expect(editor.lineTextForScreenRow(0)).toBe "var quicksort = function () {" - expect(editor.lineTextForScreenRow(1)).toBe " var sort = function(items) {" + editor.displayLayer.foldCharacter - expect(editor.getLastScreenRow()).toBe 4 - - languageMode.foldAllAtIndentLevel(2) - expect(editor.lineTextForScreenRow(0)).toBe "var quicksort = function () {" - expect(editor.lineTextForScreenRow(1)).toBe " var sort = function(items) {" - expect(editor.lineTextForScreenRow(2)).toBe " if (items.length <= 1) return items;" - expect(editor.getLastScreenRow()).toBe 9 - - describe "folding with comments", -> - beforeEach -> - waitsForPromise -> - atom.workspace.open('sample-with-comments.js', autoIndent: false).then (o) -> - editor = o - {buffer, languageMode} = editor - - waitsForPromise -> - atom.packages.activatePackage('language-javascript') - - afterEach -> - waitsForPromise -> - atom.packages.deactivatePackages() - runs -> - atom.packages.unloadPackages() - - describe ".unfoldAll()", -> - it "unfolds every folded line", -> - initialScreenLineCount = editor.getScreenLineCount() - languageMode.foldBufferRow(0) - languageMode.foldBufferRow(5) - expect(editor.getScreenLineCount()).toBeLessThan initialScreenLineCount - languageMode.unfoldAll() - expect(editor.getScreenLineCount()).toBe initialScreenLineCount - - describe ".foldAll()", -> - it "folds every foldable line", -> - languageMode.foldAll() - - folds = languageMode.unfoldAll() - expect(folds.length).toBe 8 - expect([folds[0].start.row, folds[0].end.row]).toEqual [0, 30] - expect([folds[1].start.row, folds[1].end.row]).toEqual [1, 4] - expect([folds[2].start.row, folds[2].end.row]).toEqual [5, 27] - expect([folds[3].start.row, folds[3].end.row]).toEqual [6, 8] - expect([folds[4].start.row, folds[4].end.row]).toEqual [11, 16] - expect([folds[5].start.row, folds[5].end.row]).toEqual [17, 20] - expect([folds[6].start.row, folds[6].end.row]).toEqual [21, 22] - expect([folds[7].start.row, folds[7].end.row]).toEqual [24, 25] - - describe ".foldAllAtIndentLevel()", -> - it "folds every foldable range at a given indentLevel", -> - languageMode.foldAllAtIndentLevel(2) - - folds = languageMode.unfoldAll() - expect(folds.length).toBe 5 - expect([folds[0].start.row, folds[0].end.row]).toEqual [6, 8] - expect([folds[1].start.row, folds[1].end.row]).toEqual [11, 16] - expect([folds[2].start.row, folds[2].end.row]).toEqual [17, 20] - expect([folds[3].start.row, folds[3].end.row]).toEqual [21, 22] - expect([folds[4].start.row, folds[4].end.row]).toEqual [24, 25] - - it "does not fold anything but the indentLevel", -> - languageMode.foldAllAtIndentLevel(0) - - folds = languageMode.unfoldAll() - expect(folds.length).toBe 1 - expect([folds[0].start.row, folds[0].end.row]).toEqual [0, 30] - - describe ".isFoldableAtBufferRow(bufferRow)", -> - it "returns true if the line starts a multi-line comment", -> - expect(languageMode.isFoldableAtBufferRow(1)).toBe true - expect(languageMode.isFoldableAtBufferRow(6)).toBe true - expect(languageMode.isFoldableAtBufferRow(8)).toBe false - expect(languageMode.isFoldableAtBufferRow(11)).toBe true - expect(languageMode.isFoldableAtBufferRow(15)).toBe false - expect(languageMode.isFoldableAtBufferRow(17)).toBe true - expect(languageMode.isFoldableAtBufferRow(21)).toBe true - expect(languageMode.isFoldableAtBufferRow(24)).toBe true - expect(languageMode.isFoldableAtBufferRow(28)).toBe false - - it "returns true for lines that end with a comment and are followed by an indented line", -> - expect(languageMode.isFoldableAtBufferRow(5)).toBe true - - it "does not return true for a line in the middle of a comment that's followed by an indented line", -> - expect(languageMode.isFoldableAtBufferRow(7)).toBe false - editor.buffer.insert([8, 0], ' ') - expect(languageMode.isFoldableAtBufferRow(7)).toBe false - - describe "css", -> - beforeEach -> - waitsForPromise -> - atom.workspace.open('css.css', autoIndent: true).then (o) -> - editor = o - {buffer, languageMode} = editor - - waitsForPromise -> - atom.packages.activatePackage('language-source') - atom.packages.activatePackage('language-css') - - afterEach -> - waitsForPromise -> - atom.packages.deactivatePackages() - runs -> - atom.packages.unloadPackages() - - describe "suggestedIndentForBufferRow", -> - it "does not return negative values (regression)", -> - editor.setText('.test {\npadding: 0;\n}') - expect(editor.suggestedIndentForBufferRow(2)).toBe 0 diff --git a/spec/package-manager-spec.coffee b/spec/package-manager-spec.coffee deleted file mode 100644 index 9b7d46340..000000000 --- a/spec/package-manager-spec.coffee +++ /dev/null @@ -1,1410 +0,0 @@ -path = require 'path' -Package = require '../src/package' -PackageManager = require '../src/package-manager' -temp = require('temp').track() -fs = require 'fs-plus' -{Disposable} = require 'atom' -{buildKeydownEvent} = require '../src/keymap-extensions' -{mockLocalStorage} = require './spec-helper' -ModuleCache = require '../src/module-cache' - -describe "PackageManager", -> - createTestElement = (className) -> - element = document.createElement('div') - element.className = className - element - - beforeEach -> - spyOn(ModuleCache, 'add') - - afterEach -> - try - temp.cleanupSync() - - describe "initialize", -> - it "adds regular package path", -> - packageManger = new PackageManager({}) - configDirPath = path.join('~', 'someConfig') - packageManger.initialize({configDirPath}) - expect(packageManger.packageDirPaths.length).toBe 1 - expect(packageManger.packageDirPaths[0]).toBe path.join(configDirPath, 'packages') - - it "adds regular package path and dev package path in dev mode", -> - packageManger = new PackageManager({}) - configDirPath = path.join('~', 'someConfig') - packageManger.initialize({configDirPath, devMode: true}) - expect(packageManger.packageDirPaths.length).toBe 2 - expect(packageManger.packageDirPaths).toContain path.join(configDirPath, 'packages') - expect(packageManger.packageDirPaths).toContain path.join(configDirPath, 'dev', 'packages') - - describe "::getApmPath()", -> - it "returns the path to the apm command", -> - apmPath = path.join(process.resourcesPath, "app", "apm", "bin", "apm") - if process.platform is 'win32' - apmPath += ".cmd" - expect(atom.packages.getApmPath()).toBe apmPath - - describe "when the core.apmPath setting is set", -> - beforeEach -> - atom.config.set("core.apmPath", "/path/to/apm") - - it "returns the value of the core.apmPath config setting", -> - expect(atom.packages.getApmPath()).toBe "/path/to/apm" - - describe "::loadPackages()", -> - beforeEach -> - spyOn(atom.packages, 'loadAvailablePackage') - - afterEach -> - waitsForPromise -> - atom.packages.deactivatePackages() - runs -> - atom.packages.unloadPackages() - - it "sets hasLoadedInitialPackages", -> - expect(atom.packages.hasLoadedInitialPackages()).toBe false - atom.packages.loadPackages() - expect(atom.packages.hasLoadedInitialPackages()).toBe true - - describe "::loadPackage(name)", -> - beforeEach -> - atom.config.set("core.disabledPackages", []) - - it "returns the package", -> - pack = atom.packages.loadPackage("package-with-index") - expect(pack instanceof Package).toBe true - expect(pack.metadata.name).toBe "package-with-index" - - it "returns the package if it has an invalid keymap", -> - spyOn(atom, 'inSpecMode').andReturn(false) - pack = atom.packages.loadPackage("package-with-broken-keymap") - expect(pack instanceof Package).toBe true - expect(pack.metadata.name).toBe "package-with-broken-keymap" - - it "returns the package if it has an invalid stylesheet", -> - spyOn(atom, 'inSpecMode').andReturn(false) - pack = atom.packages.loadPackage("package-with-invalid-styles") - expect(pack instanceof Package).toBe true - expect(pack.metadata.name).toBe "package-with-invalid-styles" - expect(pack.stylesheets.length).toBe 0 - - addErrorHandler = jasmine.createSpy() - atom.notifications.onDidAddNotification(addErrorHandler) - expect(-> pack.reloadStylesheets()).not.toThrow() - expect(addErrorHandler.callCount).toBe 2 - expect(addErrorHandler.argsForCall[1][0].message).toContain("Failed to reload the package-with-invalid-styles package stylesheets") - expect(addErrorHandler.argsForCall[1][0].options.packageName).toEqual "package-with-invalid-styles" - - it "returns null if the package has an invalid package.json", -> - spyOn(atom, 'inSpecMode').andReturn(false) - addErrorHandler = jasmine.createSpy() - atom.notifications.onDidAddNotification(addErrorHandler) - expect(atom.packages.loadPackage("package-with-broken-package-json")).toBeNull() - expect(addErrorHandler.callCount).toBe 1 - expect(addErrorHandler.argsForCall[0][0].message).toContain("Failed to load the package-with-broken-package-json package") - expect(addErrorHandler.argsForCall[0][0].options.packageName).toEqual "package-with-broken-package-json" - - it "returns null if the package name or path starts with a dot", -> - expect(atom.packages.loadPackage("/Users/user/.atom/packages/.git")).toBeNull() - - it "normalizes short repository urls in package.json", -> - {metadata} = atom.packages.loadPackage("package-with-short-url-package-json") - expect(metadata.repository.type).toBe "git" - expect(metadata.repository.url).toBe "https://github.com/example/repo" - - {metadata} = atom.packages.loadPackage("package-with-invalid-url-package-json") - expect(metadata.repository.type).toBe "git" - expect(metadata.repository.url).toBe "foo" - - it "trims git+ from the beginning and .git from the end of repository URLs, even if npm already normalized them ", -> - {metadata} = atom.packages.loadPackage("package-with-prefixed-and-suffixed-repo-url") - expect(metadata.repository.type).toBe "git" - expect(metadata.repository.url).toBe "https://github.com/example/repo" - - it "returns null if the package is not found in any package directory", -> - spyOn(console, 'warn') - expect(atom.packages.loadPackage("this-package-cannot-be-found")).toBeNull() - expect(console.warn.callCount).toBe(1) - expect(console.warn.argsForCall[0][0]).toContain("Could not resolve") - - describe "when the package is deprecated", -> - it "returns null", -> - spyOn(console, 'warn') - expect(atom.packages.loadPackage(path.join(__dirname, 'fixtures', 'packages', 'wordcount'))).toBeNull() - expect(atom.packages.isDeprecatedPackage('wordcount', '2.1.9')).toBe true - expect(atom.packages.isDeprecatedPackage('wordcount', '2.2.0')).toBe true - expect(atom.packages.isDeprecatedPackage('wordcount', '2.2.1')).toBe false - expect(atom.packages.getDeprecatedPackageMetadata('wordcount').version).toBe '<=2.2.0' - - it "invokes ::onDidLoadPackage listeners with the loaded package", -> - loadedPackage = null - atom.packages.onDidLoadPackage (pack) -> loadedPackage = pack - - atom.packages.loadPackage("package-with-main") - - expect(loadedPackage.name).toBe "package-with-main" - - it "registers any deserializers specified in the package's package.json", -> - pack = atom.packages.loadPackage("package-with-deserializers") - - state1 = {deserializer: 'Deserializer1', a: 'b'} - expect(atom.deserializers.deserialize(state1)).toEqual { - wasDeserializedBy: 'deserializeMethod1' - state: state1 - } - - state2 = {deserializer: 'Deserializer2', c: 'd'} - expect(atom.deserializers.deserialize(state2)).toEqual { - wasDeserializedBy: 'deserializeMethod2' - state: state2 - } - - it "early-activates any atom.directory-provider or atom.repository-provider services that the package provide", -> - jasmine.useRealClock() - - providers = [] - atom.packages.serviceHub.consume 'atom.directory-provider', '^0.1.0', (provider) -> - providers.push(provider) - - atom.packages.loadPackage('package-with-directory-provider') - expect(providers.map((p) -> p.name)).toEqual(['directory provider from package-with-directory-provider']) - - describe "when there are view providers specified in the package's package.json", -> - model1 = {worksWithViewProvider1: true} - model2 = {worksWithViewProvider2: true} - - afterEach -> - waitsForPromise -> - atom.packages.deactivatePackage('package-with-view-providers') - runs -> - atom.packages.unloadPackage('package-with-view-providers') - - it "does not load the view providers immediately", -> - pack = atom.packages.loadPackage("package-with-view-providers") - expect(pack.mainModule).toBeNull() - - expect(-> atom.views.getView(model1)).toThrow() - expect(-> atom.views.getView(model2)).toThrow() - - it "registers the view providers when the package is activated", -> - pack = atom.packages.loadPackage("package-with-view-providers") - - waitsForPromise -> - atom.packages.activatePackage("package-with-view-providers").then -> - element1 = atom.views.getView(model1) - expect(element1 instanceof HTMLDivElement).toBe true - expect(element1.dataset.createdBy).toBe 'view-provider-1' - - element2 = atom.views.getView(model2) - expect(element2 instanceof HTMLDivElement).toBe true - expect(element2.dataset.createdBy).toBe 'view-provider-2' - - it "registers the view providers when any of the package's deserializers are used", -> - pack = atom.packages.loadPackage("package-with-view-providers") - - spyOn(atom.views, 'addViewProvider').andCallThrough() - atom.deserializers.deserialize({ - deserializer: 'DeserializerFromPackageWithViewProviders', - a: 'b' - }) - expect(atom.views.addViewProvider.callCount).toBe 2 - - atom.deserializers.deserialize({ - deserializer: 'DeserializerFromPackageWithViewProviders', - a: 'b' - }) - expect(atom.views.addViewProvider.callCount).toBe 2 - - element1 = atom.views.getView(model1) - expect(element1 instanceof HTMLDivElement).toBe true - expect(element1.dataset.createdBy).toBe 'view-provider-1' - - element2 = atom.views.getView(model2) - expect(element2 instanceof HTMLDivElement).toBe true - expect(element2.dataset.createdBy).toBe 'view-provider-2' - - it "registers the config schema in the package's metadata, if present", -> - pack = atom.packages.loadPackage("package-with-json-config-schema") - expect(atom.config.getSchema('package-with-json-config-schema')).toEqual { - type: 'object' - properties: { - a: {type: 'number', default: 5} - b: {type: 'string', default: 'five'} - } - } - - expect(pack.mainModule).toBeNull() - - atom.packages.unloadPackage('package-with-json-config-schema') - atom.config.clear() - - pack = atom.packages.loadPackage("package-with-json-config-schema") - expect(atom.config.getSchema('package-with-json-config-schema')).toEqual { - type: 'object' - properties: { - a: {type: 'number', default: 5} - b: {type: 'string', default: 'five'} - } - } - - describe "when a package does not have deserializers, view providers or a config schema in its package.json", -> - beforeEach -> - mockLocalStorage() - - it "defers loading the package's main module if the package previously used no Atom APIs when its main module was required", -> - pack1 = atom.packages.loadPackage('package-with-main') - expect(pack1.mainModule).toBeDefined() - - atom.packages.unloadPackage('package-with-main') - - pack2 = atom.packages.loadPackage('package-with-main') - expect(pack2.mainModule).toBeNull() - - it "does not defer loading the package's main module if the package previously used Atom APIs when its main module was required", -> - pack1 = atom.packages.loadPackage('package-with-eval-time-api-calls') - expect(pack1.mainModule).toBeDefined() - - atom.packages.unloadPackage('package-with-eval-time-api-calls') - - pack2 = atom.packages.loadPackage('package-with-eval-time-api-calls') - expect(pack2.mainModule).not.toBeNull() - - describe "::loadAvailablePackage(availablePackage)", -> - describe "if the package was preloaded", -> - it "adds the package path to the module cache", -> - availablePackage = atom.packages.getAvailablePackages().find (p) -> p.name is 'spell-check' - availablePackage.isBundled = true - expect(atom.packages.preloadedPackages[availablePackage.name]).toBeUndefined() - expect(atom.packages.isPackageLoaded(availablePackage.name)).toBe(false) - - metadata = atom.packages.loadPackageMetadata(availablePackage) - atom.packages.preloadPackage( - availablePackage.name, - { - rootDirPath: path.relative(atom.packages.resourcePath, availablePackage.path), - metadata - } - ) - atom.packages.loadAvailablePackage(availablePackage) - expect(atom.packages.isPackageLoaded(availablePackage.name)).toBe(true) - expect(ModuleCache.add).toHaveBeenCalledWith(availablePackage.path, metadata) - - it "deactivates it if it had been disabled", -> - availablePackage = atom.packages.getAvailablePackages().find (p) -> p.name is 'spell-check' - availablePackage.isBundled = true - expect(atom.packages.preloadedPackages[availablePackage.name]).toBeUndefined() - expect(atom.packages.isPackageLoaded(availablePackage.name)).toBe(false) - - metadata = atom.packages.loadPackageMetadata(availablePackage) - preloadedPackage = atom.packages.preloadPackage( - availablePackage.name, - { - rootDirPath: path.relative(atom.packages.resourcePath, availablePackage.path), - metadata - } - ) - expect(preloadedPackage.keymapActivated).toBe(true) - expect(preloadedPackage.settingsActivated).toBe(true) - expect(preloadedPackage.menusActivated).toBe(true) - - atom.packages.loadAvailablePackage(availablePackage, new Set([availablePackage.name])) - expect(atom.packages.isPackageLoaded(availablePackage.name)).toBe(false) - expect(preloadedPackage.keymapActivated).toBe(false) - expect(preloadedPackage.settingsActivated).toBe(false) - expect(preloadedPackage.menusActivated).toBe(false) - - it "deactivates it and reloads the new one if trying to load the same package outside of the bundle", -> - availablePackage = atom.packages.getAvailablePackages().find (p) -> p.name is 'spell-check' - availablePackage.isBundled = true - expect(atom.packages.preloadedPackages[availablePackage.name]).toBeUndefined() - expect(atom.packages.isPackageLoaded(availablePackage.name)).toBe(false) - - metadata = atom.packages.loadPackageMetadata(availablePackage) - preloadedPackage = atom.packages.preloadPackage( - availablePackage.name, - { - rootDirPath: path.relative(atom.packages.resourcePath, availablePackage.path), - metadata - } - ) - expect(preloadedPackage.keymapActivated).toBe(true) - expect(preloadedPackage.settingsActivated).toBe(true) - expect(preloadedPackage.menusActivated).toBe(true) - - availablePackage.isBundled = false - atom.packages.loadAvailablePackage(availablePackage) - expect(atom.packages.isPackageLoaded(availablePackage.name)).toBe(true) - expect(preloadedPackage.keymapActivated).toBe(false) - expect(preloadedPackage.settingsActivated).toBe(false) - expect(preloadedPackage.menusActivated).toBe(false) - - describe "if the package was not preloaded", -> - it "adds the package path to the module cache", -> - availablePackage = atom.packages.getAvailablePackages().find (p) -> p.name is 'spell-check' - availablePackage.isBundled = true - metadata = atom.packages.loadPackageMetadata(availablePackage) - atom.packages.loadAvailablePackage(availablePackage) - expect(ModuleCache.add).toHaveBeenCalledWith(availablePackage.path, metadata) - - describe "preloading", -> - it "requires the main module, loads the config schema and activates keymaps, menus and settings without reactivating them during package activation", -> - availablePackage = atom.packages.getAvailablePackages().find (p) -> p.name is 'spell-check' - availablePackage.isBundled = true - metadata = atom.packages.loadPackageMetadata(availablePackage) - expect(atom.packages.preloadedPackages[availablePackage.name]).toBeUndefined() - expect(atom.packages.isPackageLoaded(availablePackage.name)).toBe(false) - - atom.packages.packagesCache = {} - atom.packages.packagesCache[availablePackage.name] = { - main: path.join(availablePackage.path, metadata.main), - grammarPaths: [] - } - preloadedPackage = atom.packages.preloadPackage( - availablePackage.name, - { - rootDirPath: path.relative(atom.packages.resourcePath, availablePackage.path), - metadata - } - ) - expect(preloadedPackage.keymapActivated).toBe(true) - expect(preloadedPackage.settingsActivated).toBe(true) - expect(preloadedPackage.menusActivated).toBe(true) - expect(preloadedPackage.mainModule).toBeTruthy() - expect(preloadedPackage.configSchemaRegisteredOnLoad).toBeTruthy() - - spyOn(atom.keymaps, 'add') - spyOn(atom.menu, 'add') - spyOn(atom.contextMenu, 'add') - spyOn(atom.config, 'setSchema') - - atom.packages.loadAvailablePackage(availablePackage) - expect(preloadedPackage.getMainModulePath()).toBe(path.join(availablePackage.path, metadata.main)) - - atom.packages.activatePackage(availablePackage.name) - expect(atom.keymaps.add).not.toHaveBeenCalled() - expect(atom.menu.add).not.toHaveBeenCalled() - expect(atom.contextMenu.add).not.toHaveBeenCalled() - expect(atom.config.setSchema).not.toHaveBeenCalled() - expect(preloadedPackage.keymapActivated).toBe(true) - expect(preloadedPackage.settingsActivated).toBe(true) - expect(preloadedPackage.menusActivated).toBe(true) - expect(preloadedPackage.mainModule).toBeTruthy() - expect(preloadedPackage.configSchemaRegisteredOnLoad).toBeTruthy() - - it "deactivates disabled keymaps during package activation", -> - availablePackage = atom.packages.getAvailablePackages().find (p) -> p.name is 'spell-check' - availablePackage.isBundled = true - metadata = atom.packages.loadPackageMetadata(availablePackage) - expect(atom.packages.preloadedPackages[availablePackage.name]).toBeUndefined() - expect(atom.packages.isPackageLoaded(availablePackage.name)).toBe(false) - - atom.packages.packagesCache = {} - atom.packages.packagesCache[availablePackage.name] = { - main: path.join(availablePackage.path, metadata.main), - grammarPaths: [] - } - preloadedPackage = atom.packages.preloadPackage( - availablePackage.name, - { - rootDirPath: path.relative(atom.packages.resourcePath, availablePackage.path), - metadata - } - ) - expect(preloadedPackage.keymapActivated).toBe(true) - expect(preloadedPackage.settingsActivated).toBe(true) - expect(preloadedPackage.menusActivated).toBe(true) - - atom.packages.loadAvailablePackage(availablePackage) - atom.config.set("core.packagesWithKeymapsDisabled", [availablePackage.name]) - atom.packages.activatePackage(availablePackage.name) - - expect(preloadedPackage.keymapActivated).toBe(false) - expect(preloadedPackage.settingsActivated).toBe(true) - expect(preloadedPackage.menusActivated).toBe(true) - - describe "::unloadPackage(name)", -> - describe "when the package is active", -> - it "throws an error", -> - pack = null - waitsForPromise -> - atom.packages.activatePackage('package-with-main').then (p) -> pack = p - - runs -> - expect(atom.packages.isPackageLoaded(pack.name)).toBeTruthy() - expect(atom.packages.isPackageActive(pack.name)).toBeTruthy() - expect( -> atom.packages.unloadPackage(pack.name)).toThrow() - expect(atom.packages.isPackageLoaded(pack.name)).toBeTruthy() - expect(atom.packages.isPackageActive(pack.name)).toBeTruthy() - - describe "when the package is not loaded", -> - it "throws an error", -> - expect(atom.packages.isPackageLoaded('unloaded')).toBeFalsy() - expect( -> atom.packages.unloadPackage('unloaded')).toThrow() - expect(atom.packages.isPackageLoaded('unloaded')).toBeFalsy() - - describe "when the package is loaded", -> - it "no longers reports it as being loaded", -> - pack = atom.packages.loadPackage('package-with-main') - expect(atom.packages.isPackageLoaded(pack.name)).toBeTruthy() - atom.packages.unloadPackage(pack.name) - expect(atom.packages.isPackageLoaded(pack.name)).toBeFalsy() - - it "invokes ::onDidUnloadPackage listeners with the unloaded package", -> - atom.packages.loadPackage('package-with-main') - unloadedPackage = null - atom.packages.onDidUnloadPackage (pack) -> unloadedPackage = pack - atom.packages.unloadPackage('package-with-main') - expect(unloadedPackage.name).toBe 'package-with-main' - - describe "::activatePackage(id)", -> - describe "when called multiple times", -> - it "it only calls activate on the package once", -> - spyOn(Package.prototype, 'activateNow').andCallThrough() - waitsForPromise -> - atom.packages.activatePackage('package-with-index') - waitsForPromise -> - atom.packages.activatePackage('package-with-index') - waitsForPromise -> - atom.packages.activatePackage('package-with-index') - - runs -> - expect(Package.prototype.activateNow.callCount).toBe 1 - - describe "when the package has a main module", -> - describe "when the metadata specifies a main module path˜", -> - it "requires the module at the specified path", -> - mainModule = require('./fixtures/packages/package-with-main/main-module') - spyOn(mainModule, 'activate') - pack = null - waitsForPromise -> - atom.packages.activatePackage('package-with-main').then (p) -> pack = p - - runs -> - expect(mainModule.activate).toHaveBeenCalled() - expect(pack.mainModule).toBe mainModule - - describe "when the metadata does not specify a main module", -> - it "requires index.coffee", -> - indexModule = require('./fixtures/packages/package-with-index/index') - spyOn(indexModule, 'activate') - pack = null - waitsForPromise -> - atom.packages.activatePackage('package-with-index').then (p) -> pack = p - - runs -> - expect(indexModule.activate).toHaveBeenCalled() - expect(pack.mainModule).toBe indexModule - - it "assigns config schema, including defaults when package contains a schema", -> - expect(atom.config.get('package-with-config-schema.numbers.one')).toBeUndefined() - - waitsForPromise -> - atom.packages.activatePackage('package-with-config-schema') - - runs -> - expect(atom.config.get('package-with-config-schema.numbers.one')).toBe 1 - expect(atom.config.get('package-with-config-schema.numbers.two')).toBe 2 - - expect(atom.config.set('package-with-config-schema.numbers.one', 'nope')).toBe false - expect(atom.config.set('package-with-config-schema.numbers.one', '10')).toBe true - expect(atom.config.get('package-with-config-schema.numbers.one')).toBe 10 - - describe "when the package metadata includes `activationCommands`", -> - [mainModule, promise, workspaceCommandListener, registration] = [] - - beforeEach -> - jasmine.attachToDOM(atom.workspace.getElement()) - mainModule = require './fixtures/packages/package-with-activation-commands/index' - mainModule.activationCommandCallCount = 0 - spyOn(mainModule, 'activate').andCallThrough() - spyOn(Package.prototype, 'requireMainModule').andCallThrough() - - workspaceCommandListener = jasmine.createSpy('workspaceCommandListener') - registration = atom.commands.add '.workspace', 'activation-command', workspaceCommandListener - - promise = atom.packages.activatePackage('package-with-activation-commands') - - afterEach -> - registration?.dispose() - mainModule = null - - it "defers requiring/activating the main module until an activation event bubbles to the root view", -> - expect(Package.prototype.requireMainModule.callCount).toBe 0 - - atom.workspace.getElement().dispatchEvent(new CustomEvent('activation-command', bubbles: true)) - - waitsForPromise -> - promise - - runs -> - expect(Package.prototype.requireMainModule.callCount).toBe 1 - - it "triggers the activation event on all handlers registered during activation", -> - waitsForPromise -> - atom.workspace.open() - - runs -> - editorElement = atom.workspace.getActiveTextEditor().getElement() - editorCommandListener = jasmine.createSpy("editorCommandListener") - atom.commands.add 'atom-text-editor', 'activation-command', editorCommandListener - atom.commands.dispatch(editorElement, 'activation-command') - expect(mainModule.activate.callCount).toBe 1 - expect(mainModule.activationCommandCallCount).toBe 1 - expect(editorCommandListener.callCount).toBe 1 - expect(workspaceCommandListener.callCount).toBe 1 - atom.commands.dispatch(editorElement, 'activation-command') - expect(mainModule.activationCommandCallCount).toBe 2 - expect(editorCommandListener.callCount).toBe 2 - expect(workspaceCommandListener.callCount).toBe 2 - expect(mainModule.activate.callCount).toBe 1 - - it "activates the package immediately when the events are empty", -> - mainModule = require './fixtures/packages/package-with-empty-activation-commands/index' - spyOn(mainModule, 'activate').andCallThrough() - - waitsForPromise -> - atom.packages.activatePackage('package-with-empty-activation-commands') - - runs -> - expect(mainModule.activate.callCount).toBe 1 - - it "adds a notification when the activation commands are invalid", -> - spyOn(atom, 'inSpecMode').andReturn(false) - addErrorHandler = jasmine.createSpy() - atom.notifications.onDidAddNotification(addErrorHandler) - expect(-> atom.packages.activatePackage('package-with-invalid-activation-commands')).not.toThrow() - expect(addErrorHandler.callCount).toBe 1 - expect(addErrorHandler.argsForCall[0][0].message).toContain("Failed to activate the package-with-invalid-activation-commands package") - expect(addErrorHandler.argsForCall[0][0].options.packageName).toEqual "package-with-invalid-activation-commands" - - it "adds a notification when the context menu is invalid", -> - spyOn(atom, 'inSpecMode').andReturn(false) - addErrorHandler = jasmine.createSpy() - atom.notifications.onDidAddNotification(addErrorHandler) - expect(-> atom.packages.activatePackage('package-with-invalid-context-menu')).not.toThrow() - expect(addErrorHandler.callCount).toBe 1 - expect(addErrorHandler.argsForCall[0][0].message).toContain("Failed to activate the package-with-invalid-context-menu package") - expect(addErrorHandler.argsForCall[0][0].options.packageName).toEqual "package-with-invalid-context-menu" - - it "adds a notification when the grammar is invalid", -> - addErrorHandler = jasmine.createSpy() - atom.notifications.onDidAddNotification(addErrorHandler) - - expect(-> atom.packages.activatePackage('package-with-invalid-grammar')).not.toThrow() - - waitsFor -> - addErrorHandler.callCount > 0 - - runs -> - expect(addErrorHandler.callCount).toBe 1 - expect(addErrorHandler.argsForCall[0][0].message).toContain("Failed to load a package-with-invalid-grammar package grammar") - expect(addErrorHandler.argsForCall[0][0].options.packageName).toEqual "package-with-invalid-grammar" - - it "adds a notification when the settings are invalid", -> - addErrorHandler = jasmine.createSpy() - atom.notifications.onDidAddNotification(addErrorHandler) - - expect(-> atom.packages.activatePackage('package-with-invalid-settings')).not.toThrow() - - waitsFor -> - addErrorHandler.callCount > 0 - - runs -> - expect(addErrorHandler.callCount).toBe 1 - expect(addErrorHandler.argsForCall[0][0].message).toContain("Failed to load the package-with-invalid-settings package settings") - expect(addErrorHandler.argsForCall[0][0].options.packageName).toEqual "package-with-invalid-settings" - - describe "when the package metadata includes `activationHooks`", -> - [mainModule, promise] = [] - - beforeEach -> - mainModule = require './fixtures/packages/package-with-activation-hooks/index' - spyOn(mainModule, 'activate').andCallThrough() - spyOn(Package.prototype, 'requireMainModule').andCallThrough() - - it "defers requiring/activating the main module until an triggering of an activation hook occurs", -> - promise = atom.packages.activatePackage('package-with-activation-hooks') - expect(Package.prototype.requireMainModule.callCount).toBe 0 - atom.packages.triggerActivationHook('language-fictitious:grammar-used') - atom.packages.triggerDeferredActivationHooks() - - waitsForPromise -> - promise - - runs -> - expect(Package.prototype.requireMainModule.callCount).toBe 1 - - it "does not double register activation hooks when deactivating and reactivating", -> - promise = atom.packages.activatePackage('package-with-activation-hooks') - expect(mainModule.activate.callCount).toBe 0 - atom.packages.triggerActivationHook('language-fictitious:grammar-used') - atom.packages.triggerDeferredActivationHooks() - - waitsForPromise -> - promise - - runs -> - expect(mainModule.activate.callCount).toBe 1 - - waitsForPromise -> - atom.packages.deactivatePackage('package-with-activation-hooks') - - runs -> - promise = atom.packages.activatePackage('package-with-activation-hooks') - atom.packages.triggerActivationHook('language-fictitious:grammar-used') - atom.packages.triggerDeferredActivationHooks() - - waitsForPromise -> - promise - - runs -> - expect(mainModule.activate.callCount).toBe 2 - - it "activates the package immediately when activationHooks is empty", -> - mainModule = require './fixtures/packages/package-with-empty-activation-hooks/index' - spyOn(mainModule, 'activate').andCallThrough() - - runs -> - expect(Package.prototype.requireMainModule.callCount).toBe 0 - - waitsForPromise -> - atom.packages.activatePackage('package-with-empty-activation-hooks') - - runs -> - expect(mainModule.activate.callCount).toBe 1 - expect(Package.prototype.requireMainModule.callCount).toBe 1 - - it "activates the package immediately if the activation hook had already been triggered", -> - atom.packages.triggerActivationHook('language-fictitious:grammar-used') - atom.packages.triggerDeferredActivationHooks() - expect(Package.prototype.requireMainModule.callCount).toBe 0 - - waitsForPromise -> - atom.packages.activatePackage('package-with-activation-hooks') - - runs -> - expect(Package.prototype.requireMainModule.callCount).toBe 1 - - describe "when the package has no main module", -> - it "does not throw an exception", -> - spyOn(console, "error") - spyOn(console, "warn").andCallThrough() - expect(-> atom.packages.activatePackage('package-without-module')).not.toThrow() - expect(console.error).not.toHaveBeenCalled() - expect(console.warn).not.toHaveBeenCalled() - - describe "when the package does not export an activate function", -> - it "activates the package and does not throw an exception or log a warning", -> - spyOn(console, "warn") - expect(-> atom.packages.activatePackage('package-with-no-activate')).not.toThrow() - - waitsFor -> - atom.packages.isPackageActive('package-with-no-activate') - - runs -> - expect(console.warn).not.toHaveBeenCalled() - - it "passes the activate method the package's previously serialized state if it exists", -> - pack = null - waitsForPromise -> - atom.packages.activatePackage("package-with-serialization").then (p) -> pack = p - runs -> - expect(pack.mainModule.someNumber).not.toBe 77 - pack.mainModule.someNumber = 77 - atom.packages.serializePackage("package-with-serialization") - waitsForPromise -> - atom.packages.deactivatePackage("package-with-serialization") - runs -> - spyOn(pack.mainModule, 'activate').andCallThrough() - waitsForPromise -> - atom.packages.activatePackage("package-with-serialization") - runs -> - expect(pack.mainModule.activate).toHaveBeenCalledWith({someNumber: 77}) - - it "invokes ::onDidActivatePackage listeners with the activated package", -> - activatedPackage = null - atom.packages.onDidActivatePackage (pack) -> - activatedPackage = pack - - atom.packages.activatePackage('package-with-main') - - waitsFor -> activatedPackage? - runs -> expect(activatedPackage.name).toBe 'package-with-main' - - describe "when the package's main module throws an error on load", -> - it "adds a notification instead of throwing an exception", -> - spyOn(atom, 'inSpecMode').andReturn(false) - atom.config.set("core.disabledPackages", []) - addErrorHandler = jasmine.createSpy() - atom.notifications.onDidAddNotification(addErrorHandler) - expect(-> atom.packages.activatePackage("package-that-throws-an-exception")).not.toThrow() - expect(addErrorHandler.callCount).toBe 1 - expect(addErrorHandler.argsForCall[0][0].message).toContain("Failed to load the package-that-throws-an-exception package") - expect(addErrorHandler.argsForCall[0][0].options.packageName).toEqual "package-that-throws-an-exception" - - it "re-throws the exception in test mode", -> - atom.config.set("core.disabledPackages", []) - addErrorHandler = jasmine.createSpy() - expect(-> atom.packages.activatePackage("package-that-throws-an-exception")).toThrow("This package throws an exception") - - describe "when the package is not found", -> - it "rejects the promise", -> - atom.config.set("core.disabledPackages", []) - - onSuccess = jasmine.createSpy('onSuccess') - onFailure = jasmine.createSpy('onFailure') - spyOn(console, 'warn') - - atom.packages.activatePackage("this-doesnt-exist").then(onSuccess, onFailure) - - waitsFor "promise to be rejected", -> - onFailure.callCount > 0 - - runs -> - expect(console.warn.callCount).toBe 1 - expect(onFailure.mostRecentCall.args[0] instanceof Error).toBe true - expect(onFailure.mostRecentCall.args[0].message).toContain "Failed to load package 'this-doesnt-exist'" - - describe "keymap loading", -> - describe "when the metadata does not contain a 'keymaps' manifest", -> - it "loads all the .cson/.json files in the keymaps directory", -> - element1 = createTestElement('test-1') - element2 = createTestElement('test-2') - element3 = createTestElement('test-3') - - expect(atom.keymaps.findKeyBindings(keystrokes: 'ctrl-z', target: element1)).toHaveLength 0 - expect(atom.keymaps.findKeyBindings(keystrokes: 'ctrl-z', target: element2)).toHaveLength 0 - expect(atom.keymaps.findKeyBindings(keystrokes: 'ctrl-z', target: element3)).toHaveLength 0 - - waitsForPromise -> - atom.packages.activatePackage("package-with-keymaps") - - runs -> - expect(atom.keymaps.findKeyBindings(keystrokes: 'ctrl-z', target: element1)[0].command).toBe "test-1" - expect(atom.keymaps.findKeyBindings(keystrokes: 'ctrl-z', target: element2)[0].command).toBe "test-2" - expect(atom.keymaps.findKeyBindings(keystrokes: 'ctrl-z', target: element3)).toHaveLength 0 - - describe "when the metadata contains a 'keymaps' manifest", -> - it "loads only the keymaps specified by the manifest, in the specified order", -> - element1 = createTestElement('test-1') - element3 = createTestElement('test-3') - - expect(atom.keymaps.findKeyBindings(keystrokes: 'ctrl-z', target: element1)).toHaveLength 0 - - waitsForPromise -> - atom.packages.activatePackage("package-with-keymaps-manifest") - - runs -> - expect(atom.keymaps.findKeyBindings(keystrokes: 'ctrl-z', target: element1)[0].command).toBe 'keymap-1' - expect(atom.keymaps.findKeyBindings(keystrokes: 'ctrl-n', target: element1)[0].command).toBe 'keymap-2' - expect(atom.keymaps.findKeyBindings(keystrokes: 'ctrl-y', target: element3)).toHaveLength 0 - - describe "when the keymap file is empty", -> - it "does not throw an error on activation", -> - waitsForPromise -> - atom.packages.activatePackage("package-with-empty-keymap") - - runs -> - expect(atom.packages.isPackageActive("package-with-empty-keymap")).toBe true - - describe "when the package's keymaps have been disabled", -> - it "does not add the keymaps", -> - element1 = createTestElement('test-1') - - expect(atom.keymaps.findKeyBindings(keystrokes: 'ctrl-z', target: element1)).toHaveLength 0 - - atom.config.set("core.packagesWithKeymapsDisabled", ["package-with-keymaps-manifest"]) - - waitsForPromise -> - atom.packages.activatePackage("package-with-keymaps-manifest") - - runs -> - expect(atom.keymaps.findKeyBindings(keystrokes: 'ctrl-z', target: element1)).toHaveLength 0 - - describe "when setting core.packagesWithKeymapsDisabled", -> - it "ignores package names in the array that aren't loaded", -> - atom.packages.observePackagesWithKeymapsDisabled() - - expect(-> atom.config.set("core.packagesWithKeymapsDisabled", ["package-does-not-exist"])).not.toThrow() - expect(-> atom.config.set("core.packagesWithKeymapsDisabled", [])).not.toThrow() - - describe "when the package's keymaps are disabled and re-enabled after it is activated", -> - it "removes and re-adds the keymaps", -> - element1 = createTestElement('test-1') - atom.packages.observePackagesWithKeymapsDisabled() - - waitsForPromise -> - atom.packages.activatePackage("package-with-keymaps-manifest") - - runs -> - atom.config.set("core.packagesWithKeymapsDisabled", ['package-with-keymaps-manifest']) - expect(atom.keymaps.findKeyBindings(keystrokes: 'ctrl-z', target: element1)).toHaveLength 0 - - atom.config.set("core.packagesWithKeymapsDisabled", []) - expect(atom.keymaps.findKeyBindings(keystrokes: 'ctrl-z', target: element1)[0].command).toBe 'keymap-1' - - describe "when the package is de-activated and re-activated", -> - [element, events, userKeymapPath] = [] - - beforeEach -> - userKeymapPath = path.join(temp.mkdirSync(), "user-keymaps.cson") - spyOn(atom.keymaps, "getUserKeymapPath").andReturn(userKeymapPath) - - element = createTestElement('test-1') - jasmine.attachToDOM(element) - - events = [] - element.addEventListener 'user-command', (e) -> events.push(e) - element.addEventListener 'test-1', (e) -> events.push(e) - - afterEach -> - element.remove() - - # Avoid leaking user keymap subscription - atom.keymaps.watchSubscriptions[userKeymapPath].dispose() - delete atom.keymaps.watchSubscriptions[userKeymapPath] - - temp.cleanupSync() - - it "doesn't override user-defined keymaps", -> - fs.writeFileSync userKeymapPath, """ - ".test-1": - "ctrl-z": "user-command" - """ - atom.keymaps.loadUserKeymap() - - waitsForPromise -> - atom.packages.activatePackage("package-with-keymaps") - - runs -> - atom.keymaps.handleKeyboardEvent(buildKeydownEvent("z", ctrl: true, target: element)) - - expect(events.length).toBe(1) - expect(events[0].type).toBe("user-command") - - waitsForPromise -> - atom.packages.deactivatePackage("package-with-keymaps") - - waitsForPromise -> - atom.packages.activatePackage("package-with-keymaps") - - runs -> - atom.keymaps.handleKeyboardEvent(buildKeydownEvent("z", ctrl: true, target: element)) - - expect(events.length).toBe(2) - expect(events[1].type).toBe("user-command") - - describe "menu loading", -> - beforeEach -> - atom.contextMenu.definitions = [] - atom.menu.template = [] - - describe "when the metadata does not contain a 'menus' manifest", -> - it "loads all the .cson/.json files in the menus directory", -> - element = createTestElement('test-1') - - expect(atom.contextMenu.templateForElement(element)).toEqual [] - - waitsForPromise -> - atom.packages.activatePackage("package-with-menus") - - runs -> - expect(atom.menu.template.length).toBe 2 - expect(atom.menu.template[0].label).toBe "Second to Last" - expect(atom.menu.template[1].label).toBe "Last" - expect(atom.contextMenu.templateForElement(element)[0].label).toBe "Menu item 1" - expect(atom.contextMenu.templateForElement(element)[1].label).toBe "Menu item 2" - expect(atom.contextMenu.templateForElement(element)[2].label).toBe "Menu item 3" - - describe "when the metadata contains a 'menus' manifest", -> - it "loads only the menus specified by the manifest, in the specified order", -> - element = createTestElement('test-1') - - expect(atom.contextMenu.templateForElement(element)).toEqual [] - - waitsForPromise -> - atom.packages.activatePackage("package-with-menus-manifest") - - runs -> - expect(atom.menu.template[0].label).toBe "Second to Last" - expect(atom.menu.template[1].label).toBe "Last" - expect(atom.contextMenu.templateForElement(element)[0].label).toBe "Menu item 2" - expect(atom.contextMenu.templateForElement(element)[1].label).toBe "Menu item 1" - expect(atom.contextMenu.templateForElement(element)[2]).toBeUndefined() - - describe "when the menu file is empty", -> - it "does not throw an error on activation", -> - waitsForPromise -> - atom.packages.activatePackage("package-with-empty-menu") - - runs -> - expect(atom.packages.isPackageActive("package-with-empty-menu")).toBe true - - describe "stylesheet loading", -> - describe "when the metadata contains a 'styleSheets' manifest", -> - it "loads style sheets from the styles directory as specified by the manifest", -> - one = require.resolve("./fixtures/packages/package-with-style-sheets-manifest/styles/1.css") - two = require.resolve("./fixtures/packages/package-with-style-sheets-manifest/styles/2.less") - three = require.resolve("./fixtures/packages/package-with-style-sheets-manifest/styles/3.css") - - expect(atom.themes.stylesheetElementForId(one)).toBeNull() - expect(atom.themes.stylesheetElementForId(two)).toBeNull() - expect(atom.themes.stylesheetElementForId(three)).toBeNull() - - waitsForPromise -> - atom.packages.activatePackage("package-with-style-sheets-manifest") - - runs -> - expect(atom.themes.stylesheetElementForId(one)).not.toBeNull() - expect(atom.themes.stylesheetElementForId(two)).not.toBeNull() - expect(atom.themes.stylesheetElementForId(three)).toBeNull() - - expect(getComputedStyle(document.querySelector('#jasmine-content')).fontSize).toBe '1px' - - describe "when the metadata does not contain a 'styleSheets' manifest", -> - it "loads all style sheets from the styles directory", -> - one = require.resolve("./fixtures/packages/package-with-styles/styles/1.css") - two = require.resolve("./fixtures/packages/package-with-styles/styles/2.less") - three = require.resolve("./fixtures/packages/package-with-styles/styles/3.test-context.css") - four = require.resolve("./fixtures/packages/package-with-styles/styles/4.css") - - expect(atom.themes.stylesheetElementForId(one)).toBeNull() - expect(atom.themes.stylesheetElementForId(two)).toBeNull() - expect(atom.themes.stylesheetElementForId(three)).toBeNull() - expect(atom.themes.stylesheetElementForId(four)).toBeNull() - - waitsForPromise -> - atom.packages.activatePackage("package-with-styles") - - runs -> - expect(atom.themes.stylesheetElementForId(one)).not.toBeNull() - expect(atom.themes.stylesheetElementForId(two)).not.toBeNull() - expect(atom.themes.stylesheetElementForId(three)).not.toBeNull() - expect(atom.themes.stylesheetElementForId(four)).not.toBeNull() - expect(getComputedStyle(document.querySelector('#jasmine-content')).fontSize).toBe '3px' - - it "assigns the stylesheet's context based on the filename", -> - waitsForPromise -> - atom.packages.activatePackage("package-with-styles") - - runs -> - count = 0 - - for styleElement in atom.styles.getStyleElements() - if styleElement.sourcePath.match /1.css/ - expect(styleElement.context).toBe undefined - count++ - - if styleElement.sourcePath.match /2.less/ - expect(styleElement.context).toBe undefined - count++ - - if styleElement.sourcePath.match /3.test-context.css/ - expect(styleElement.context).toBe 'test-context' - count++ - - if styleElement.sourcePath.match /4.css/ - expect(styleElement.context).toBe undefined - count++ - - expect(count).toBe 4 - - describe "grammar loading", -> - it "loads the package's grammars", -> - waitsForPromise -> - atom.packages.activatePackage('package-with-grammars') - - runs -> - expect(atom.grammars.selectGrammar('a.alot').name).toBe 'Alot' - expect(atom.grammars.selectGrammar('a.alittle').name).toBe 'Alittle' - - describe "scoped-property loading", -> - it "loads the scoped properties", -> - waitsForPromise -> - atom.packages.activatePackage("package-with-settings") - - runs -> - expect(atom.config.get 'editor.increaseIndentPattern', scope: ['.source.omg']).toBe '^a' - - describe "service registration", -> - it "registers the package's provided and consumed services", -> - consumerModule = require "./fixtures/packages/package-with-consumed-services" - firstServiceV3Disposed = false - firstServiceV4Disposed = false - secondServiceDisposed = false - spyOn(consumerModule, 'consumeFirstServiceV3').andReturn(new Disposable -> firstServiceV3Disposed = true) - spyOn(consumerModule, 'consumeFirstServiceV4').andReturn(new Disposable -> firstServiceV4Disposed = true) - spyOn(consumerModule, 'consumeSecondService').andReturn(new Disposable -> secondServiceDisposed = true) - - waitsForPromise -> - atom.packages.activatePackage("package-with-consumed-services") - - waitsForPromise -> - atom.packages.activatePackage("package-with-provided-services") - - runs -> - expect(consumerModule.consumeFirstServiceV3.callCount).toBe(1) - expect(consumerModule.consumeFirstServiceV3).toHaveBeenCalledWith('first-service-v3') - expect(consumerModule.consumeFirstServiceV4).toHaveBeenCalledWith('first-service-v4') - expect(consumerModule.consumeSecondService).toHaveBeenCalledWith('second-service') - - consumerModule.consumeFirstServiceV3.reset() - consumerModule.consumeFirstServiceV4.reset() - consumerModule.consumeSecondService.reset() - - waitsForPromise -> - atom.packages.deactivatePackage("package-with-provided-services") - - runs -> - expect(firstServiceV3Disposed).toBe true - expect(firstServiceV4Disposed).toBe true - expect(secondServiceDisposed).toBe true - - waitsForPromise -> - atom.packages.deactivatePackage("package-with-consumed-services") - - waitsForPromise -> - atom.packages.activatePackage("package-with-provided-services") - - runs -> - expect(consumerModule.consumeFirstServiceV3).not.toHaveBeenCalled() - expect(consumerModule.consumeFirstServiceV4).not.toHaveBeenCalled() - expect(consumerModule.consumeSecondService).not.toHaveBeenCalled() - - it "ignores provided and consumed services that do not exist", -> - addErrorHandler = jasmine.createSpy() - atom.notifications.onDidAddNotification(addErrorHandler) - - waitsForPromise -> - atom.packages.activatePackage("package-with-missing-consumed-services") - - waitsForPromise -> - atom.packages.activatePackage("package-with-missing-provided-services") - - runs -> - expect(atom.packages.isPackageActive("package-with-missing-consumed-services")).toBe true - expect(atom.packages.isPackageActive("package-with-missing-provided-services")).toBe true - expect(addErrorHandler.callCount).toBe 0 - - describe "::serialize", -> - it "does not serialize packages that threw an error during activation", -> - spyOn(atom, 'inSpecMode').andReturn(false) - spyOn(console, 'warn') - badPack = null - waitsForPromise -> - atom.packages.activatePackage("package-that-throws-on-activate").then (p) -> badPack = p - - runs -> - spyOn(badPack.mainModule, 'serialize').andCallThrough() - - atom.packages.serialize() - expect(badPack.mainModule.serialize).not.toHaveBeenCalled() - - it "absorbs exceptions that are thrown by the package module's serialize method", -> - spyOn(console, 'error') - - waitsForPromise -> - atom.packages.activatePackage('package-with-serialize-error') - - waitsForPromise -> - atom.packages.activatePackage('package-with-serialization') - - runs -> - atom.packages.serialize() - expect(atom.packages.packageStates['package-with-serialize-error']).toBeUndefined() - expect(atom.packages.packageStates['package-with-serialization']).toEqual someNumber: 1 - expect(console.error).toHaveBeenCalled() - - describe "::deactivatePackages()", -> - it "deactivates all packages but does not serialize them", -> - [pack1, pack2] = [] - - waitsForPromise -> - atom.packages.activatePackage("package-with-deactivate").then (p) -> pack1 = p - atom.packages.activatePackage("package-with-serialization").then (p) -> pack2 = p - - runs -> - spyOn(pack1.mainModule, 'deactivate') - spyOn(pack2.mainModule, 'serialize') - - waitsForPromise -> - atom.packages.deactivatePackages() - - runs -> - expect(pack1.mainModule.deactivate).toHaveBeenCalled() - expect(pack2.mainModule.serialize).not.toHaveBeenCalled() - - describe "::deactivatePackage(id)", -> - afterEach -> - atom.packages.unloadPackages() - - it "calls `deactivate` on the package's main module if activate was successful", -> - spyOn(atom, 'inSpecMode').andReturn(false) - pack = null - waitsForPromise -> - atom.packages.activatePackage("package-with-deactivate").then (p) -> pack = p - - runs -> - expect(atom.packages.isPackageActive("package-with-deactivate")).toBeTruthy() - spyOn(pack.mainModule, 'deactivate').andCallThrough() - - waitsForPromise -> - atom.packages.deactivatePackage("package-with-deactivate") - - runs -> - expect(pack.mainModule.deactivate).toHaveBeenCalled() - expect(atom.packages.isPackageActive("package-with-module")).toBeFalsy() - - spyOn(console, 'warn') - - badPack = null - waitsForPromise -> - atom.packages.activatePackage("package-that-throws-on-activate").then (p) -> badPack = p - - runs -> - expect(atom.packages.isPackageActive("package-that-throws-on-activate")).toBeTruthy() - spyOn(badPack.mainModule, 'deactivate').andCallThrough() - - waitsForPromise -> - atom.packages.deactivatePackage("package-that-throws-on-activate") - - runs -> - expect(badPack.mainModule.deactivate).not.toHaveBeenCalled() - expect(atom.packages.isPackageActive("package-that-throws-on-activate")).toBeFalsy() - - it "absorbs exceptions that are thrown by the package module's deactivate method", -> - spyOn(console, 'error') - thrownError = null - - waitsForPromise -> - atom.packages.activatePackage("package-that-throws-on-deactivate") - - waitsForPromise -> - try - atom.packages.deactivatePackage("package-that-throws-on-deactivate") - catch error - thrownError = error - - runs -> - expect(thrownError).toBeNull() - expect(console.error).toHaveBeenCalled() - - it "removes the package's grammars", -> - waitsForPromise -> - atom.packages.activatePackage('package-with-grammars') - - waitsForPromise -> - atom.packages.deactivatePackage('package-with-grammars') - - runs -> - expect(atom.grammars.selectGrammar('a.alot').name).toBe 'Null Grammar' - expect(atom.grammars.selectGrammar('a.alittle').name).toBe 'Null Grammar' - - it "removes the package's keymaps", -> - waitsForPromise -> - atom.packages.activatePackage('package-with-keymaps') - - waitsForPromise -> - atom.packages.deactivatePackage('package-with-keymaps') - - runs -> - expect(atom.keymaps.findKeyBindings(keystrokes: 'ctrl-z', target: createTestElement('test-1'))).toHaveLength 0 - expect(atom.keymaps.findKeyBindings(keystrokes: 'ctrl-z', target: createTestElement('test-2'))).toHaveLength 0 - - it "removes the package's stylesheets", -> - waitsForPromise -> - atom.packages.activatePackage('package-with-styles') - - waitsForPromise -> - atom.packages.deactivatePackage('package-with-styles') - - runs -> - one = require.resolve("./fixtures/packages/package-with-style-sheets-manifest/styles/1.css") - two = require.resolve("./fixtures/packages/package-with-style-sheets-manifest/styles/2.less") - three = require.resolve("./fixtures/packages/package-with-style-sheets-manifest/styles/3.css") - expect(atom.themes.stylesheetElementForId(one)).not.toExist() - expect(atom.themes.stylesheetElementForId(two)).not.toExist() - expect(atom.themes.stylesheetElementForId(three)).not.toExist() - - it "removes the package's scoped-properties", -> - waitsForPromise -> - atom.packages.activatePackage("package-with-settings") - - runs -> - expect(atom.config.get 'editor.increaseIndentPattern', scope: ['.source.omg']).toBe '^a' - - waitsForPromise -> - atom.packages.deactivatePackage("package-with-settings") - - runs -> - expect(atom.config.get 'editor.increaseIndentPattern', scope: ['.source.omg']).toBeUndefined() - - it "invokes ::onDidDeactivatePackage listeners with the deactivated package", -> - deactivatedPackage = null - - waitsForPromise -> - atom.packages.activatePackage("package-with-main") - - runs -> - atom.packages.onDidDeactivatePackage (pack) -> deactivatedPackage = pack - - waitsForPromise -> - atom.packages.deactivatePackage("package-with-main") - - runs -> - expect(deactivatedPackage.name).toBe "package-with-main" - - describe "::activate()", -> - beforeEach -> - spyOn(atom, 'inSpecMode').andReturn(false) - jasmine.snapshotDeprecations() - spyOn(console, 'warn') - atom.packages.loadPackages() - - loadedPackages = atom.packages.getLoadedPackages() - expect(loadedPackages.length).toBeGreaterThan 0 - - afterEach -> - waitsForPromise -> - atom.packages.deactivatePackages() - runs -> - atom.packages.unloadPackages() - jasmine.restoreDeprecationsSnapshot() - - it "sets hasActivatedInitialPackages", -> - spyOn(atom.styles, 'getUserStyleSheetPath').andReturn(null) - spyOn(atom.packages, 'activatePackages') - expect(atom.packages.hasActivatedInitialPackages()).toBe false - waitsForPromise -> atom.packages.activate() - runs -> expect(atom.packages.hasActivatedInitialPackages()).toBe true - - it "activates all the packages, and none of the themes", -> - packageActivator = spyOn(atom.packages, 'activatePackages') - themeActivator = spyOn(atom.themes, 'activatePackages') - - atom.packages.activate() - - expect(packageActivator).toHaveBeenCalled() - expect(themeActivator).toHaveBeenCalled() - - packages = packageActivator.mostRecentCall.args[0] - expect(['atom', 'textmate']).toContain(pack.getType()) for pack in packages - - themes = themeActivator.mostRecentCall.args[0] - expect(['theme']).toContain(theme.getType()) for theme in themes - - it "calls callbacks registered with ::onDidActivateInitialPackages", -> - package1 = atom.packages.loadPackage('package-with-main') - package2 = atom.packages.loadPackage('package-with-index') - package3 = atom.packages.loadPackage('package-with-activation-commands') - spyOn(atom.packages, 'getLoadedPackages').andReturn([package1, package2, package3]) - spyOn(atom.themes, 'activatePackages') - activateSpy = jasmine.createSpy('activateSpy') - atom.packages.onDidActivateInitialPackages(activateSpy) - - atom.packages.activate() - waitsFor -> activateSpy.callCount > 0 - runs -> - jasmine.unspy(atom.packages, 'getLoadedPackages') - expect(package1 in atom.packages.getActivePackages()).toBe true - expect(package2 in atom.packages.getActivePackages()).toBe true - expect(package3 in atom.packages.getActivePackages()).toBe false - - describe "::enablePackage(id) and ::disablePackage(id)", -> - describe "with packages", -> - it "enables a disabled package", -> - packageName = 'package-with-main' - atom.config.pushAtKeyPath('core.disabledPackages', packageName) - atom.packages.observeDisabledPackages() - expect(atom.config.get('core.disabledPackages')).toContain packageName - - pack = atom.packages.enablePackage(packageName) - loadedPackages = atom.packages.getLoadedPackages() - activatedPackages = null - waitsFor -> - activatedPackages = atom.packages.getActivePackages() - activatedPackages.length > 0 - - runs -> - expect(loadedPackages).toContain(pack) - expect(activatedPackages).toContain(pack) - expect(atom.config.get('core.disabledPackages')).not.toContain packageName - - it "disables an enabled package", -> - packageName = 'package-with-main' - pack = null - activatedPackages = null - - waitsForPromise -> - atom.packages.activatePackage(packageName) - - runs -> - atom.packages.observeDisabledPackages() - expect(atom.config.get('core.disabledPackages')).not.toContain packageName - - pack = atom.packages.disablePackage(packageName) - - waitsFor -> - activatedPackages = atom.packages.getActivePackages() - activatedPackages.length is 0 - - runs -> - expect(activatedPackages).not.toContain(pack) - expect(atom.config.get('core.disabledPackages')).toContain packageName - - it "returns null if the package cannot be loaded", -> - spyOn(console, 'warn') - expect(atom.packages.enablePackage("this-doesnt-exist")).toBeNull() - expect(console.warn.callCount).toBe 1 - - it "does not disable an already disabled package", -> - packageName = 'package-with-main' - atom.config.pushAtKeyPath('core.disabledPackages', packageName) - atom.packages.observeDisabledPackages() - expect(atom.config.get('core.disabledPackages')).toContain packageName - - atom.packages.disablePackage(packageName) - packagesDisabled = atom.config.get('core.disabledPackages').filter((pack) -> pack is packageName) - expect(packagesDisabled.length).toEqual 1 - - describe "with themes", -> - didChangeActiveThemesHandler = null - - beforeEach -> - waitsForPromise -> - atom.themes.activateThemes() - - afterEach -> - waitsForPromise -> - atom.themes.deactivateThemes() - - it "enables and disables a theme", -> - packageName = 'theme-with-package-file' - - expect(atom.config.get('core.themes')).not.toContain packageName - expect(atom.config.get('core.disabledPackages')).not.toContain packageName - - # enabling of theme - pack = atom.packages.enablePackage(packageName) - - waitsFor 'theme to enable', 500, -> - pack in atom.packages.getActivePackages() - - runs -> - expect(atom.config.get('core.themes')).toContain packageName - expect(atom.config.get('core.disabledPackages')).not.toContain packageName - - didChangeActiveThemesHandler = jasmine.createSpy('didChangeActiveThemesHandler') - didChangeActiveThemesHandler.reset() - atom.themes.onDidChangeActiveThemes didChangeActiveThemesHandler - - pack = atom.packages.disablePackage(packageName) - - waitsFor 'did-change-active-themes event to fire', 500, -> - didChangeActiveThemesHandler.callCount is 1 - - runs -> - expect(atom.packages.getActivePackages()).not.toContain pack - expect(atom.config.get('core.themes')).not.toContain packageName - expect(atom.config.get('core.themes')).not.toContain packageName - expect(atom.config.get('core.disabledPackages')).not.toContain packageName diff --git a/spec/package-manager-spec.js b/spec/package-manager-spec.js new file mode 100644 index 000000000..1d949859d --- /dev/null +++ b/spec/package-manager-spec.js @@ -0,0 +1,1339 @@ +const path = require('path') +const Package = require('../src/package') +const PackageManager = require('../src/package-manager') +const temp = require('temp').track() +const fs = require('fs-plus') +const {Disposable} = require('atom') +const {buildKeydownEvent} = require('../src/keymap-extensions') +const {mockLocalStorage} = require('./spec-helper') +const ModuleCache = require('../src/module-cache') +const {it, fit, ffit, beforeEach, afterEach} = require('./async-spec-helpers') + +describe('PackageManager', () => { + function createTestElement (className) { + const element = document.createElement('div') + element.className = className + return element + } + + beforeEach(() => { + spyOn(ModuleCache, 'add') + }) + + describe('initialize', () => { + it('adds regular package path', () => { + const packageManger = new PackageManager({}) + const configDirPath = path.join('~', 'someConfig') + packageManger.initialize({configDirPath}) + expect(packageManger.packageDirPaths.length).toBe(1) + expect(packageManger.packageDirPaths[0]).toBe(path.join(configDirPath, 'packages')) + }) + + it('adds regular package path and dev package path in dev mode', () => { + const packageManger = new PackageManager({}) + const configDirPath = path.join('~', 'someConfig') + packageManger.initialize({configDirPath, devMode: true}) + expect(packageManger.packageDirPaths.length).toBe(2) + expect(packageManger.packageDirPaths).toContain(path.join(configDirPath, 'packages')) + expect(packageManger.packageDirPaths).toContain(path.join(configDirPath, 'dev', 'packages')) + }) + }) + + describe('::getApmPath()', () => { + it('returns the path to the apm command', () => { + let apmPath = path.join(process.resourcesPath, 'app', 'apm', 'bin', 'apm') + if (process.platform === 'win32') { + apmPath += '.cmd' + } + expect(atom.packages.getApmPath()).toBe(apmPath) + }) + + describe('when the core.apmPath setting is set', () => { + beforeEach(() => atom.config.set('core.apmPath', '/path/to/apm')) + + it('returns the value of the core.apmPath config setting', () => { + expect(atom.packages.getApmPath()).toBe('/path/to/apm') + }) + }) + }) + + describe('::loadPackages()', () => { + beforeEach(() => spyOn(atom.packages, 'loadAvailablePackage')) + + afterEach(async () => { + await atom.packages.deactivatePackages() + atom.packages.unloadPackages() + }) + + it('sets hasLoadedInitialPackages', () => { + expect(atom.packages.hasLoadedInitialPackages()).toBe(false) + atom.packages.loadPackages() + expect(atom.packages.hasLoadedInitialPackages()).toBe(true) + }) + }) + + describe('::loadPackage(name)', () => { + beforeEach(() => atom.config.set('core.disabledPackages', [])) + + it('returns the package', () => { + const pack = atom.packages.loadPackage('package-with-index') + expect(pack instanceof Package).toBe(true) + expect(pack.metadata.name).toBe('package-with-index') + }) + + it('returns the package if it has an invalid keymap', () => { + spyOn(atom, 'inSpecMode').andReturn(false) + const pack = atom.packages.loadPackage('package-with-broken-keymap') + expect(pack instanceof Package).toBe(true) + expect(pack.metadata.name).toBe('package-with-broken-keymap') + }) + + it('returns the package if it has an invalid stylesheet', () => { + spyOn(atom, 'inSpecMode').andReturn(false) + const pack = atom.packages.loadPackage('package-with-invalid-styles') + expect(pack instanceof Package).toBe(true) + expect(pack.metadata.name).toBe('package-with-invalid-styles') + expect(pack.stylesheets.length).toBe(0) + + const addErrorHandler = jasmine.createSpy() + atom.notifications.onDidAddNotification(addErrorHandler) + expect(() => pack.reloadStylesheets()).not.toThrow() + expect(addErrorHandler.callCount).toBe(2) + expect(addErrorHandler.argsForCall[1][0].message).toContain('Failed to reload the package-with-invalid-styles package stylesheets') + expect(addErrorHandler.argsForCall[1][0].options.packageName).toEqual('package-with-invalid-styles') + }) + + it('returns null if the package has an invalid package.json', () => { + spyOn(atom, 'inSpecMode').andReturn(false) + const addErrorHandler = jasmine.createSpy() + atom.notifications.onDidAddNotification(addErrorHandler) + expect(atom.packages.loadPackage('package-with-broken-package-json')).toBeNull() + expect(addErrorHandler.callCount).toBe(1) + expect(addErrorHandler.argsForCall[0][0].message).toContain('Failed to load the package-with-broken-package-json package') + expect(addErrorHandler.argsForCall[0][0].options.packageName).toEqual('package-with-broken-package-json') + }) + + it('returns null if the package name or path starts with a dot', () => { + expect(atom.packages.loadPackage('/Users/user/.atom/packages/.git')).toBeNull() + }) + + it('normalizes short repository urls in package.json', () => { + let {metadata} = atom.packages.loadPackage('package-with-short-url-package-json') + expect(metadata.repository.type).toBe('git') + expect(metadata.repository.url).toBe('https://github.com/example/repo'); + + ({metadata} = atom.packages.loadPackage('package-with-invalid-url-package-json')) + expect(metadata.repository.type).toBe('git') + expect(metadata.repository.url).toBe('foo') + }) + + it('trims git+ from the beginning and .git from the end of repository URLs, even if npm already normalized them ', () => { + const {metadata} = atom.packages.loadPackage('package-with-prefixed-and-suffixed-repo-url') + expect(metadata.repository.type).toBe('git') + expect(metadata.repository.url).toBe('https://github.com/example/repo') + }) + + it('returns null if the package is not found in any package directory', () => { + spyOn(console, 'warn') + expect(atom.packages.loadPackage('this-package-cannot-be-found')).toBeNull() + expect(console.warn.callCount).toBe(1) + expect(console.warn.argsForCall[0][0]).toContain('Could not resolve') + }) + + describe('when the package is deprecated', () => { + it('returns null', () => { + spyOn(console, 'warn') + expect(atom.packages.loadPackage(path.join(__dirname, 'fixtures', 'packages', 'wordcount'))).toBeNull() + expect(atom.packages.isDeprecatedPackage('wordcount', '2.1.9')).toBe(true) + expect(atom.packages.isDeprecatedPackage('wordcount', '2.2.0')).toBe(true) + expect(atom.packages.isDeprecatedPackage('wordcount', '2.2.1')).toBe(false) + expect(atom.packages.getDeprecatedPackageMetadata('wordcount').version).toBe('<=2.2.0') + }) + }) + + it('invokes ::onDidLoadPackage listeners with the loaded package', () => { + let loadedPackage = null + + atom.packages.onDidLoadPackage(pack => { + loadedPackage = pack + }) + + atom.packages.loadPackage('package-with-main') + + expect(loadedPackage.name).toBe('package-with-main') + }) + + it("registers any deserializers specified in the package's package.json", () => { + atom.packages.loadPackage('package-with-deserializers') + + const state1 = {deserializer: 'Deserializer1', a: 'b'} + expect(atom.deserializers.deserialize(state1)).toEqual({ + wasDeserializedBy: 'deserializeMethod1', + state: state1 + }) + + const state2 = {deserializer: 'Deserializer2', c: 'd'} + expect(atom.deserializers.deserialize(state2)).toEqual({ + wasDeserializedBy: 'deserializeMethod2', + state: state2 + }) + }) + + it('early-activates any atom.directory-provider or atom.repository-provider services that the package provide', () => { + jasmine.useRealClock() + + const providers = [] + atom.packages.serviceHub.consume('atom.directory-provider', '^0.1.0', provider => providers.push(provider)) + + atom.packages.loadPackage('package-with-directory-provider') + expect(providers.map(p => p.name)).toEqual(['directory provider from package-with-directory-provider']) + }) + + describe("when there are view providers specified in the package's package.json", () => { + const model1 = {worksWithViewProvider1: true} + const model2 = {worksWithViewProvider2: true} + + afterEach(async () => { + await atom.packages.deactivatePackage('package-with-view-providers') + atom.packages.unloadPackage('package-with-view-providers') + }) + + it('does not load the view providers immediately', () => { + const pack = atom.packages.loadPackage('package-with-view-providers') + expect(pack.mainModule).toBeNull() + + expect(() => atom.views.getView(model1)).toThrow() + expect(() => atom.views.getView(model2)).toThrow() + }) + + it('registers the view providers when the package is activated', async () => { + atom.packages.loadPackage('package-with-view-providers') + + await atom.packages.activatePackage('package-with-view-providers') + + const element1 = atom.views.getView(model1) + expect(element1 instanceof HTMLDivElement).toBe(true) + expect(element1.dataset.createdBy).toBe('view-provider-1') + + const element2 = atom.views.getView(model2) + expect(element2 instanceof HTMLDivElement).toBe(true) + expect(element2.dataset.createdBy).toBe('view-provider-2') + }) + + it("registers the view providers when any of the package's deserializers are used", () => { + atom.packages.loadPackage('package-with-view-providers') + + spyOn(atom.views, 'addViewProvider').andCallThrough() + atom.deserializers.deserialize({ + deserializer: 'DeserializerFromPackageWithViewProviders', + a: 'b' + }) + expect(atom.views.addViewProvider.callCount).toBe(2) + + atom.deserializers.deserialize({ + deserializer: 'DeserializerFromPackageWithViewProviders', + a: 'b' + }) + expect(atom.views.addViewProvider.callCount).toBe(2) + + const element1 = atom.views.getView(model1) + expect(element1 instanceof HTMLDivElement).toBe(true) + expect(element1.dataset.createdBy).toBe('view-provider-1') + + const element2 = atom.views.getView(model2) + expect(element2 instanceof HTMLDivElement).toBe(true) + expect(element2.dataset.createdBy).toBe('view-provider-2') + }) + }) + + it("registers the config schema in the package's metadata, if present", () => { + let pack = atom.packages.loadPackage('package-with-json-config-schema') + expect(atom.config.getSchema('package-with-json-config-schema')).toEqual({ + type: 'object', + properties: { + a: {type: 'number', default: 5}, + b: {type: 'string', default: 'five'} + } + }) + + expect(pack.mainModule).toBeNull() + + atom.packages.unloadPackage('package-with-json-config-schema') + atom.config.clear() + + pack = atom.packages.loadPackage('package-with-json-config-schema') + expect(atom.config.getSchema('package-with-json-config-schema')).toEqual({ + type: 'object', + properties: { + a: {type: 'number', default: 5}, + b: {type: 'string', default: 'five'} + } + }) + }) + + describe('when a package does not have deserializers, view providers or a config schema in its package.json', () => { + beforeEach(() => mockLocalStorage()) + + it("defers loading the package's main module if the package previously used no Atom APIs when its main module was required", () => { + const pack1 = atom.packages.loadPackage('package-with-main') + expect(pack1.mainModule).toBeDefined() + + atom.packages.unloadPackage('package-with-main') + + const pack2 = atom.packages.loadPackage('package-with-main') + expect(pack2.mainModule).toBeNull() + }) + + it("does not defer loading the package's main module if the package previously used Atom APIs when its main module was required", () => { + const pack1 = atom.packages.loadPackage('package-with-eval-time-api-calls') + expect(pack1.mainModule).toBeDefined() + + atom.packages.unloadPackage('package-with-eval-time-api-calls') + + const pack2 = atom.packages.loadPackage('package-with-eval-time-api-calls') + expect(pack2.mainModule).not.toBeNull() + }) + }) + }) + + describe('::loadAvailablePackage(availablePackage)', () => { + describe('if the package was preloaded', () => { + it('adds the package path to the module cache', () => { + const availablePackage = atom.packages.getAvailablePackages().find(p => p.name === 'spell-check') + availablePackage.isBundled = true + expect(atom.packages.preloadedPackages[availablePackage.name]).toBeUndefined() + expect(atom.packages.isPackageLoaded(availablePackage.name)).toBe(false) + + const metadata = atom.packages.loadPackageMetadata(availablePackage) + atom.packages.preloadPackage( + availablePackage.name, + { + rootDirPath: path.relative(atom.packages.resourcePath, availablePackage.path), + metadata + } + ) + atom.packages.loadAvailablePackage(availablePackage) + expect(atom.packages.isPackageLoaded(availablePackage.name)).toBe(true) + expect(ModuleCache.add).toHaveBeenCalledWith(availablePackage.path, metadata) + }) + + it('deactivates it if it had been disabled', () => { + const availablePackage = atom.packages.getAvailablePackages().find(p => p.name === 'spell-check') + availablePackage.isBundled = true + expect(atom.packages.preloadedPackages[availablePackage.name]).toBeUndefined() + expect(atom.packages.isPackageLoaded(availablePackage.name)).toBe(false) + + const metadata = atom.packages.loadPackageMetadata(availablePackage) + const preloadedPackage = atom.packages.preloadPackage( + availablePackage.name, + { + rootDirPath: path.relative(atom.packages.resourcePath, availablePackage.path), + metadata + } + ) + expect(preloadedPackage.keymapActivated).toBe(true) + expect(preloadedPackage.settingsActivated).toBe(true) + expect(preloadedPackage.menusActivated).toBe(true) + + atom.packages.loadAvailablePackage(availablePackage, new Set([availablePackage.name])) + expect(atom.packages.isPackageLoaded(availablePackage.name)).toBe(false) + expect(preloadedPackage.keymapActivated).toBe(false) + expect(preloadedPackage.settingsActivated).toBe(false) + expect(preloadedPackage.menusActivated).toBe(false) + }) + + it('deactivates it and reloads the new one if trying to load the same package outside of the bundle', () => { + const availablePackage = atom.packages.getAvailablePackages().find(p => p.name === 'spell-check') + availablePackage.isBundled = true + expect(atom.packages.preloadedPackages[availablePackage.name]).toBeUndefined() + expect(atom.packages.isPackageLoaded(availablePackage.name)).toBe(false) + + const metadata = atom.packages.loadPackageMetadata(availablePackage) + const preloadedPackage = atom.packages.preloadPackage( + availablePackage.name, + { + rootDirPath: path.relative(atom.packages.resourcePath, availablePackage.path), + metadata + } + ) + expect(preloadedPackage.keymapActivated).toBe(true) + expect(preloadedPackage.settingsActivated).toBe(true) + expect(preloadedPackage.menusActivated).toBe(true) + + availablePackage.isBundled = false + atom.packages.loadAvailablePackage(availablePackage) + expect(atom.packages.isPackageLoaded(availablePackage.name)).toBe(true) + expect(preloadedPackage.keymapActivated).toBe(false) + expect(preloadedPackage.settingsActivated).toBe(false) + expect(preloadedPackage.menusActivated).toBe(false) + }) + }) + + describe('if the package was not preloaded', () => { + it('adds the package path to the module cache', () => { + const availablePackage = atom.packages.getAvailablePackages().find(p => p.name === 'spell-check') + availablePackage.isBundled = true + const metadata = atom.packages.loadPackageMetadata(availablePackage) + atom.packages.loadAvailablePackage(availablePackage) + expect(ModuleCache.add).toHaveBeenCalledWith(availablePackage.path, metadata) + }) + }) + }) + + describe('preloading', () => { + it('requires the main module, loads the config schema and activates keymaps, menus and settings without reactivating them during package activation', () => { + const availablePackage = atom.packages.getAvailablePackages().find(p => p.name === 'spell-check') + availablePackage.isBundled = true + const metadata = atom.packages.loadPackageMetadata(availablePackage) + expect(atom.packages.preloadedPackages[availablePackage.name]).toBeUndefined() + expect(atom.packages.isPackageLoaded(availablePackage.name)).toBe(false) + + atom.packages.packagesCache = {} + atom.packages.packagesCache[availablePackage.name] = { + main: path.join(availablePackage.path, metadata.main), + grammarPaths: [] + } + const preloadedPackage = atom.packages.preloadPackage( + availablePackage.name, + { + rootDirPath: path.relative(atom.packages.resourcePath, availablePackage.path), + metadata + } + ) + expect(preloadedPackage.keymapActivated).toBe(true) + expect(preloadedPackage.settingsActivated).toBe(true) + expect(preloadedPackage.menusActivated).toBe(true) + expect(preloadedPackage.mainModule).toBeTruthy() + expect(preloadedPackage.configSchemaRegisteredOnLoad).toBeTruthy() + + spyOn(atom.keymaps, 'add') + spyOn(atom.menu, 'add') + spyOn(atom.contextMenu, 'add') + spyOn(atom.config, 'setSchema') + + atom.packages.loadAvailablePackage(availablePackage) + expect(preloadedPackage.getMainModulePath()).toBe(path.join(availablePackage.path, metadata.main)) + + atom.packages.activatePackage(availablePackage.name) + expect(atom.keymaps.add).not.toHaveBeenCalled() + expect(atom.menu.add).not.toHaveBeenCalled() + expect(atom.contextMenu.add).not.toHaveBeenCalled() + expect(atom.config.setSchema).not.toHaveBeenCalled() + expect(preloadedPackage.keymapActivated).toBe(true) + expect(preloadedPackage.settingsActivated).toBe(true) + expect(preloadedPackage.menusActivated).toBe(true) + expect(preloadedPackage.mainModule).toBeTruthy() + expect(preloadedPackage.configSchemaRegisteredOnLoad).toBeTruthy() + }) + + it('deactivates disabled keymaps during package activation', () => { + const availablePackage = atom.packages.getAvailablePackages().find(p => p.name === 'spell-check') + availablePackage.isBundled = true + const metadata = atom.packages.loadPackageMetadata(availablePackage) + expect(atom.packages.preloadedPackages[availablePackage.name]).toBeUndefined() + expect(atom.packages.isPackageLoaded(availablePackage.name)).toBe(false) + + atom.packages.packagesCache = {} + atom.packages.packagesCache[availablePackage.name] = { + main: path.join(availablePackage.path, metadata.main), + grammarPaths: [] + } + const preloadedPackage = atom.packages.preloadPackage( + availablePackage.name, + { + rootDirPath: path.relative(atom.packages.resourcePath, availablePackage.path), + metadata + } + ) + expect(preloadedPackage.keymapActivated).toBe(true) + expect(preloadedPackage.settingsActivated).toBe(true) + expect(preloadedPackage.menusActivated).toBe(true) + + atom.packages.loadAvailablePackage(availablePackage) + atom.config.set('core.packagesWithKeymapsDisabled', [availablePackage.name]) + atom.packages.activatePackage(availablePackage.name) + + expect(preloadedPackage.keymapActivated).toBe(false) + expect(preloadedPackage.settingsActivated).toBe(true) + expect(preloadedPackage.menusActivated).toBe(true) + }) + }) + + describe('::unloadPackage(name)', () => { + describe('when the package is active', () => { + it('throws an error', async () => { + const pack = await atom.packages.activatePackage('package-with-main') + expect(atom.packages.isPackageLoaded(pack.name)).toBeTruthy() + expect(atom.packages.isPackageActive(pack.name)).toBeTruthy() + + expect(() => atom.packages.unloadPackage(pack.name)).toThrow() + expect(atom.packages.isPackageLoaded(pack.name)).toBeTruthy() + expect(atom.packages.isPackageActive(pack.name)).toBeTruthy() + }) + }) + + describe('when the package is not loaded', () => { + it('throws an error', () => { + expect(atom.packages.isPackageLoaded('unloaded')).toBeFalsy() + expect(() => atom.packages.unloadPackage('unloaded')).toThrow() + expect(atom.packages.isPackageLoaded('unloaded')).toBeFalsy() + }) + }) + + describe('when the package is loaded', () => { + it('no longers reports it as being loaded', () => { + const pack = atom.packages.loadPackage('package-with-main') + expect(atom.packages.isPackageLoaded(pack.name)).toBeTruthy() + atom.packages.unloadPackage(pack.name) + expect(atom.packages.isPackageLoaded(pack.name)).toBeFalsy() + }) + }) + + it('invokes ::onDidUnloadPackage listeners with the unloaded package', () => { + atom.packages.loadPackage('package-with-main') + let unloadedPackage + atom.packages.onDidUnloadPackage(pack => { + unloadedPackage = pack + }) + atom.packages.unloadPackage('package-with-main') + expect(unloadedPackage.name).toBe('package-with-main') + }) + }) + + describe('::activatePackage(id)', () => { + describe('when called multiple times', () => { + it('it only calls activate on the package once', async () => { + spyOn(Package.prototype, 'activateNow').andCallThrough() + await atom.packages.activatePackage('package-with-index') + await atom.packages.activatePackage('package-with-index') + await atom.packages.activatePackage('package-with-index') + + expect(Package.prototype.activateNow.callCount).toBe(1) + }) + }) + + describe('when the package has a main module', () => { + describe('when the metadata specifies a main module path˜', () => { + it('requires the module at the specified path', async () => { + const mainModule = require('./fixtures/packages/package-with-main/main-module') + spyOn(mainModule, 'activate') + + const pack = await atom.packages.activatePackage('package-with-main') + expect(mainModule.activate).toHaveBeenCalled() + expect(pack.mainModule).toBe(mainModule) + }) + }) + + describe('when the metadata does not specify a main module', () => { + it('requires index.coffee', async () => { + const indexModule = require('./fixtures/packages/package-with-index/index') + spyOn(indexModule, 'activate') + + const pack = await atom.packages.activatePackage('package-with-index') + expect(indexModule.activate).toHaveBeenCalled() + expect(pack.mainModule).toBe(indexModule) + }) + }) + + it('assigns config schema, including defaults when package contains a schema', async () => { + expect(atom.config.get('package-with-config-schema.numbers.one')).toBeUndefined() + + await atom.packages.activatePackage('package-with-config-schema') + expect(atom.config.get('package-with-config-schema.numbers.one')).toBe(1) + expect(atom.config.get('package-with-config-schema.numbers.two')).toBe(2) + expect(atom.config.set('package-with-config-schema.numbers.one', 'nope')).toBe(false) + expect(atom.config.set('package-with-config-schema.numbers.one', '10')).toBe(true) + expect(atom.config.get('package-with-config-schema.numbers.one')).toBe(10) + }) + + describe('when the package metadata includes `activationCommands`', () => { + let mainModule, promise, workspaceCommandListener, registration + + beforeEach(() => { + jasmine.attachToDOM(atom.workspace.getElement()) + mainModule = require('./fixtures/packages/package-with-activation-commands/index') + mainModule.activationCommandCallCount = 0 + spyOn(mainModule, 'activate').andCallThrough() + spyOn(Package.prototype, 'requireMainModule').andCallThrough() + + workspaceCommandListener = jasmine.createSpy('workspaceCommandListener') + registration = atom.commands.add('.workspace', 'activation-command', workspaceCommandListener) + + promise = atom.packages.activatePackage('package-with-activation-commands') + }) + + afterEach(() => { + if (registration) { + registration.dispose() + } + mainModule = null + }) + + it('defers requiring/activating the main module until an activation event bubbles to the root view', async () => { + expect(Package.prototype.requireMainModule.callCount).toBe(0) + + atom.workspace.getElement().dispatchEvent(new CustomEvent('activation-command', {bubbles: true})) + + await promise + expect(Package.prototype.requireMainModule.callCount).toBe(1) + }) + + it('triggers the activation event on all handlers registered during activation', async () => { + await atom.workspace.open() + + const editorElement = atom.workspace.getActiveTextEditor().getElement() + const editorCommandListener = jasmine.createSpy('editorCommandListener') + atom.commands.add('atom-text-editor', 'activation-command', editorCommandListener) + + atom.commands.dispatch(editorElement, 'activation-command') + expect(mainModule.activate.callCount).toBe(1) + expect(mainModule.activationCommandCallCount).toBe(1) + expect(editorCommandListener.callCount).toBe(1) + expect(workspaceCommandListener.callCount).toBe(1) + + atom.commands.dispatch(editorElement, 'activation-command') + expect(mainModule.activationCommandCallCount).toBe(2) + expect(editorCommandListener.callCount).toBe(2) + expect(workspaceCommandListener.callCount).toBe(2) + expect(mainModule.activate.callCount).toBe(1) + }) + + it('activates the package immediately when the events are empty', async () => { + mainModule = require('./fixtures/packages/package-with-empty-activation-commands/index') + spyOn(mainModule, 'activate').andCallThrough() + + atom.packages.activatePackage('package-with-empty-activation-commands') + + expect(mainModule.activate.callCount).toBe(1) + }) + + it('adds a notification when the activation commands are invalid', () => { + spyOn(atom, 'inSpecMode').andReturn(false) + const addErrorHandler = jasmine.createSpy() + atom.notifications.onDidAddNotification(addErrorHandler) + expect(() => atom.packages.activatePackage('package-with-invalid-activation-commands')).not.toThrow() + expect(addErrorHandler.callCount).toBe(1) + expect(addErrorHandler.argsForCall[0][0].message).toContain('Failed to activate the package-with-invalid-activation-commands package') + expect(addErrorHandler.argsForCall[0][0].options.packageName).toEqual('package-with-invalid-activation-commands') + }) + + it('adds a notification when the context menu is invalid', () => { + spyOn(atom, 'inSpecMode').andReturn(false) + const addErrorHandler = jasmine.createSpy() + atom.notifications.onDidAddNotification(addErrorHandler) + expect(() => atom.packages.activatePackage('package-with-invalid-context-menu')).not.toThrow() + expect(addErrorHandler.callCount).toBe(1) + expect(addErrorHandler.argsForCall[0][0].message).toContain('Failed to activate the package-with-invalid-context-menu package') + expect(addErrorHandler.argsForCall[0][0].options.packageName).toEqual('package-with-invalid-context-menu') + }) + + it('adds a notification when the grammar is invalid', async () => { + let notificationEvent + + await new Promise(resolve => { + const subscription = atom.notifications.onDidAddNotification(event => { + notificationEvent = event + subscription.dispose() + resolve() + }) + + atom.packages.activatePackage('package-with-invalid-grammar') + }) + + expect(notificationEvent.message).toContain('Failed to load a package-with-invalid-grammar package grammar') + expect(notificationEvent.options.packageName).toEqual('package-with-invalid-grammar') + }) + + it('adds a notification when the settings are invalid', async () => { + let notificationEvent + + await new Promise(resolve => { + const subscription = atom.notifications.onDidAddNotification(event => { + notificationEvent = event + subscription.dispose() + resolve() + }) + + atom.packages.activatePackage('package-with-invalid-settings') + }) + + expect(notificationEvent.message).toContain('Failed to load the package-with-invalid-settings package settings') + expect(notificationEvent.options.packageName).toEqual('package-with-invalid-settings') + }) + }) + }) + + describe('when the package metadata includes `activationHooks`', () => { + let mainModule, promise + + beforeEach(() => { + mainModule = require('./fixtures/packages/package-with-activation-hooks/index') + spyOn(mainModule, 'activate').andCallThrough() + spyOn(Package.prototype, 'requireMainModule').andCallThrough() + }) + + it('defers requiring/activating the main module until an triggering of an activation hook occurs', async () => { + promise = atom.packages.activatePackage('package-with-activation-hooks') + expect(Package.prototype.requireMainModule.callCount).toBe(0) + atom.packages.triggerActivationHook('language-fictitious:grammar-used') + atom.packages.triggerDeferredActivationHooks() + + await promise + expect(Package.prototype.requireMainModule.callCount).toBe(1) + }) + + it('does not double register activation hooks when deactivating and reactivating', async () => { + promise = atom.packages.activatePackage('package-with-activation-hooks') + expect(mainModule.activate.callCount).toBe(0) + atom.packages.triggerActivationHook('language-fictitious:grammar-used') + atom.packages.triggerDeferredActivationHooks() + + await promise + expect(mainModule.activate.callCount).toBe(1) + + await atom.packages.deactivatePackage('package-with-activation-hooks') + + promise = atom.packages.activatePackage('package-with-activation-hooks') + atom.packages.triggerActivationHook('language-fictitious:grammar-used') + atom.packages.triggerDeferredActivationHooks() + + await promise + expect(mainModule.activate.callCount).toBe(2) + }) + + it('activates the package immediately when activationHooks is empty', async () => { + mainModule = require('./fixtures/packages/package-with-empty-activation-hooks/index') + spyOn(mainModule, 'activate').andCallThrough() + + expect(Package.prototype.requireMainModule.callCount).toBe(0) + + await atom.packages.activatePackage('package-with-empty-activation-hooks') + expect(mainModule.activate.callCount).toBe(1) + expect(Package.prototype.requireMainModule.callCount).toBe(1) + }) + + it('activates the package immediately if the activation hook had already been triggered', async () => { + atom.packages.triggerActivationHook('language-fictitious:grammar-used') + atom.packages.triggerDeferredActivationHooks() + expect(Package.prototype.requireMainModule.callCount).toBe(0) + + await atom.packages.activatePackage('package-with-activation-hooks') + expect(Package.prototype.requireMainModule.callCount).toBe(1) + }) + }) + + describe('when the package has no main module', () => { + it('does not throw an exception', () => { + spyOn(console, 'error') + spyOn(console, 'warn').andCallThrough() + expect(() => atom.packages.activatePackage('package-without-module')).not.toThrow() + expect(console.error).not.toHaveBeenCalled() + expect(console.warn).not.toHaveBeenCalled() + }) + }) + + describe('when the package does not export an activate function', () => { + it('activates the package and does not throw an exception or log a warning', async () => { + spyOn(console, 'warn') + await atom.packages.activatePackage('package-with-no-activate') + expect(console.warn).not.toHaveBeenCalled() + }) + }) + + it("passes the activate method the package's previously serialized state if it exists", async () => { + const pack = await atom.packages.activatePackage('package-with-serialization') + expect(pack.mainModule.someNumber).not.toBe(77) + pack.mainModule.someNumber = 77 + atom.packages.serializePackage('package-with-serialization') + await atom.packages.deactivatePackage('package-with-serialization') + + spyOn(pack.mainModule, 'activate').andCallThrough() + await atom.packages.activatePackage('package-with-serialization') + expect(pack.mainModule.activate).toHaveBeenCalledWith({someNumber: 77}) + }) + + it('invokes ::onDidActivatePackage listeners with the activated package', async () => { + let activatedPackage + atom.packages.onDidActivatePackage(pack => { + activatedPackage = pack + }) + + await atom.packages.activatePackage('package-with-main') + expect(activatedPackage.name).toBe('package-with-main') + }) + + describe("when the package's main module throws an error on load", () => { + it('adds a notification instead of throwing an exception', () => { + spyOn(atom, 'inSpecMode').andReturn(false) + atom.config.set('core.disabledPackages', []) + const addErrorHandler = jasmine.createSpy() + atom.notifications.onDidAddNotification(addErrorHandler) + expect(() => atom.packages.activatePackage('package-that-throws-an-exception')).not.toThrow() + expect(addErrorHandler.callCount).toBe(1) + expect(addErrorHandler.argsForCall[0][0].message).toContain('Failed to load the package-that-throws-an-exception package') + expect(addErrorHandler.argsForCall[0][0].options.packageName).toEqual('package-that-throws-an-exception') + }) + + it('re-throws the exception in test mode', () => { + atom.config.set('core.disabledPackages', []) + expect(() => atom.packages.activatePackage('package-that-throws-an-exception')).toThrow('This package throws an exception') + }) + }) + + describe('when the package is not found', () => { + it('rejects the promise', async () => { + spyOn(console, 'warn') + atom.config.set('core.disabledPackages', []) + + try { + await atom.packages.activatePackage('this-doesnt-exist') + expect('Error to be thrown').toBe('') + } catch (error) { + expect(console.warn.callCount).toBe(1) + expect(error.message).toContain("Failed to load package 'this-doesnt-exist'") + } + }) + }) + + describe('keymap loading', () => { + describe("when the metadata does not contain a 'keymaps' manifest", () => { + it('loads all the .cson/.json files in the keymaps directory', async () => { + const element1 = createTestElement('test-1') + const element2 = createTestElement('test-2') + const element3 = createTestElement('test-3') + expect(atom.keymaps.findKeyBindings({keystrokes: 'ctrl-z', target: element1})).toHaveLength(0) + expect(atom.keymaps.findKeyBindings({keystrokes: 'ctrl-z', target: element2})).toHaveLength(0) + expect(atom.keymaps.findKeyBindings({keystrokes: 'ctrl-z', target: element3})).toHaveLength(0) + + await atom.packages.activatePackage('package-with-keymaps') + expect(atom.keymaps.findKeyBindings({keystrokes: 'ctrl-z', target: element1})[0].command).toBe('test-1') + expect(atom.keymaps.findKeyBindings({keystrokes: 'ctrl-z', target: element2})[0].command).toBe('test-2') + expect(atom.keymaps.findKeyBindings({keystrokes: 'ctrl-z', target: element3})).toHaveLength(0) + }) + }) + + describe("when the metadata contains a 'keymaps' manifest", () => { + it('loads only the keymaps specified by the manifest, in the specified order', async () => { + const element1 = createTestElement('test-1') + const element3 = createTestElement('test-3') + expect(atom.keymaps.findKeyBindings({keystrokes: 'ctrl-z', target: element1})).toHaveLength(0) + + await atom.packages.activatePackage('package-with-keymaps-manifest') + expect(atom.keymaps.findKeyBindings({keystrokes: 'ctrl-z', target: element1})[0].command).toBe('keymap-1') + expect(atom.keymaps.findKeyBindings({keystrokes: 'ctrl-n', target: element1})[0].command).toBe('keymap-2') + expect(atom.keymaps.findKeyBindings({keystrokes: 'ctrl-y', target: element3})).toHaveLength(0) + }) + }) + + describe('when the keymap file is empty', () => { + it('does not throw an error on activation', async () => { + await atom.packages.activatePackage('package-with-empty-keymap') + expect(atom.packages.isPackageActive('package-with-empty-keymap')).toBe(true) + }) + }) + + describe("when the package's keymaps have been disabled", () => { + it('does not add the keymaps', async () => { + const element1 = createTestElement('test-1') + expect(atom.keymaps.findKeyBindings({keystrokes: 'ctrl-z', target: element1})).toHaveLength(0) + + atom.config.set('core.packagesWithKeymapsDisabled', ['package-with-keymaps-manifest']) + await atom.packages.activatePackage('package-with-keymaps-manifest') + expect(atom.keymaps.findKeyBindings({keystrokes: 'ctrl-z', target: element1})).toHaveLength(0) + }) + }) + + describe('when setting core.packagesWithKeymapsDisabled', () => { + it("ignores package names in the array that aren't loaded", () => { + atom.packages.observePackagesWithKeymapsDisabled() + + expect(() => atom.config.set('core.packagesWithKeymapsDisabled', ['package-does-not-exist'])).not.toThrow() + expect(() => atom.config.set('core.packagesWithKeymapsDisabled', [])).not.toThrow() + }) + }) + + describe("when the package's keymaps are disabled and re-enabled after it is activated", () => { + it('removes and re-adds the keymaps', async () => { + const element1 = createTestElement('test-1') + atom.packages.observePackagesWithKeymapsDisabled() + + await atom.packages.activatePackage('package-with-keymaps-manifest') + + atom.config.set('core.packagesWithKeymapsDisabled', ['package-with-keymaps-manifest']) + expect(atom.keymaps.findKeyBindings({keystrokes: 'ctrl-z', target: element1})).toHaveLength(0) + + atom.config.set('core.packagesWithKeymapsDisabled', []) + expect(atom.keymaps.findKeyBindings({keystrokes: 'ctrl-z', target: element1})[0].command).toBe('keymap-1') + }) + }) + + describe('when the package is de-activated and re-activated', () => { + let element, events, userKeymapPath + + beforeEach(() => { + userKeymapPath = path.join(temp.mkdirSync(), 'user-keymaps.cson') + spyOn(atom.keymaps, 'getUserKeymapPath').andReturn(userKeymapPath) + + element = createTestElement('test-1') + jasmine.attachToDOM(element) + + events = [] + element.addEventListener('user-command', e => events.push(e)) + element.addEventListener('test-1', e => events.push(e)) + }) + + afterEach(() => { + element.remove() + + // Avoid leaking user keymap subscription + atom.keymaps.watchSubscriptions[userKeymapPath].dispose() + delete atom.keymaps.watchSubscriptions[userKeymapPath] + + temp.cleanupSync() + }) + + it("doesn't override user-defined keymaps", async () => { + fs.writeFileSync(userKeymapPath, `".test-1": {"ctrl-z": "user-command"}`) + atom.keymaps.loadUserKeymap() + + await atom.packages.activatePackage('package-with-keymaps') + atom.keymaps.handleKeyboardEvent(buildKeydownEvent('z', {ctrl: true, target: element})) + expect(events.length).toBe(1) + expect(events[0].type).toBe('user-command') + + await atom.packages.deactivatePackage('package-with-keymaps') + await atom.packages.activatePackage('package-with-keymaps') + atom.keymaps.handleKeyboardEvent(buildKeydownEvent('z', {ctrl: true, target: element})) + expect(events.length).toBe(2) + expect(events[1].type).toBe('user-command') + }) + }) + }) + + describe('menu loading', () => { + beforeEach(() => { + atom.contextMenu.definitions = [] + atom.menu.template = [] + }) + + describe("when the metadata does not contain a 'menus' manifest", () => { + it('loads all the .cson/.json files in the menus directory', async () => { + const element = createTestElement('test-1') + expect(atom.contextMenu.templateForElement(element)).toEqual([]) + + await atom.packages.activatePackage('package-with-menus') + expect(atom.menu.template.length).toBe(2) + expect(atom.menu.template[0].label).toBe('Second to Last') + expect(atom.menu.template[1].label).toBe('Last') + expect(atom.contextMenu.templateForElement(element)[0].label).toBe('Menu item 1') + expect(atom.contextMenu.templateForElement(element)[1].label).toBe('Menu item 2') + expect(atom.contextMenu.templateForElement(element)[2].label).toBe('Menu item 3') + }) + }) + + describe("when the metadata contains a 'menus' manifest", () => { + it('loads only the menus specified by the manifest, in the specified order', async () => { + const element = createTestElement('test-1') + expect(atom.contextMenu.templateForElement(element)).toEqual([]) + + await atom.packages.activatePackage('package-with-menus-manifest') + expect(atom.menu.template[0].label).toBe('Second to Last') + expect(atom.menu.template[1].label).toBe('Last') + expect(atom.contextMenu.templateForElement(element)[0].label).toBe('Menu item 2') + expect(atom.contextMenu.templateForElement(element)[1].label).toBe('Menu item 1') + expect(atom.contextMenu.templateForElement(element)[2]).toBeUndefined() + }) + }) + + describe('when the menu file is empty', () => { + it('does not throw an error on activation', async () => { + await atom.packages.activatePackage('package-with-empty-menu') + expect(atom.packages.isPackageActive('package-with-empty-menu')).toBe(true) + }) + }) + }) + + describe('stylesheet loading', () => { + describe("when the metadata contains a 'styleSheets' manifest", () => { + it('loads style sheets from the styles directory as specified by the manifest', async () => { + const one = require.resolve('./fixtures/packages/package-with-style-sheets-manifest/styles/1.css') + const two = require.resolve('./fixtures/packages/package-with-style-sheets-manifest/styles/2.less') + const three = require.resolve('./fixtures/packages/package-with-style-sheets-manifest/styles/3.css') + + expect(atom.themes.stylesheetElementForId(one)).toBeNull() + expect(atom.themes.stylesheetElementForId(two)).toBeNull() + expect(atom.themes.stylesheetElementForId(three)).toBeNull() + + await atom.packages.activatePackage('package-with-style-sheets-manifest') + expect(atom.themes.stylesheetElementForId(one)).not.toBeNull() + expect(atom.themes.stylesheetElementForId(two)).not.toBeNull() + expect(atom.themes.stylesheetElementForId(three)).toBeNull() + expect(getComputedStyle(document.querySelector('#jasmine-content')).fontSize).toBe('1px') + }) + }) + + describe("when the metadata does not contain a 'styleSheets' manifest", () => { + it('loads all style sheets from the styles directory', async () => { + const one = require.resolve('./fixtures/packages/package-with-styles/styles/1.css') + const two = require.resolve('./fixtures/packages/package-with-styles/styles/2.less') + const three = require.resolve('./fixtures/packages/package-with-styles/styles/3.test-context.css') + const four = require.resolve('./fixtures/packages/package-with-styles/styles/4.css') + + expect(atom.themes.stylesheetElementForId(one)).toBeNull() + expect(atom.themes.stylesheetElementForId(two)).toBeNull() + expect(atom.themes.stylesheetElementForId(three)).toBeNull() + expect(atom.themes.stylesheetElementForId(four)).toBeNull() + + await atom.packages.activatePackage('package-with-styles') + expect(atom.themes.stylesheetElementForId(one)).not.toBeNull() + expect(atom.themes.stylesheetElementForId(two)).not.toBeNull() + expect(atom.themes.stylesheetElementForId(three)).not.toBeNull() + expect(atom.themes.stylesheetElementForId(four)).not.toBeNull() + expect(getComputedStyle(document.querySelector('#jasmine-content')).fontSize).toBe('3px') + }) + }) + + it("assigns the stylesheet's context based on the filename", async () => { + await atom.packages.activatePackage('package-with-styles') + + let count = 0 + for (let styleElement of atom.styles.getStyleElements()) { + if (styleElement.sourcePath.match(/1.css/)) { + expect(styleElement.context).toBe(undefined) + count++ + } + + if (styleElement.sourcePath.match(/2.less/)) { + expect(styleElement.context).toBe(undefined) + count++ + } + + if (styleElement.sourcePath.match(/3.test-context.css/)) { + expect(styleElement.context).toBe('test-context') + count++ + } + + if (styleElement.sourcePath.match(/4.css/)) { + expect(styleElement.context).toBe(undefined) + count++ + } + } + + expect(count).toBe(4) + }) + }) + + describe('grammar loading', () => { + it("loads the package's grammars", async () => { + await atom.packages.activatePackage('package-with-grammars') + expect(atom.grammars.selectGrammar('a.alot').name).toBe('Alot') + expect(atom.grammars.selectGrammar('a.alittle').name).toBe('Alittle') + }) + }) + + describe('scoped-property loading', () => { + it('loads the scoped properties', async () => { + await atom.packages.activatePackage('package-with-settings') + expect(atom.config.get('editor.increaseIndentPattern', {scope: ['.source.omg']})).toBe('^a') + }) + }) + + describe('service registration', () => { + it("registers the package's provided and consumed services", async () => { + const consumerModule = require('./fixtures/packages/package-with-consumed-services') + + let firstServiceV3Disposed = false + let firstServiceV4Disposed = false + let secondServiceDisposed = false + spyOn(consumerModule, 'consumeFirstServiceV3').andReturn(new Disposable(() => { firstServiceV3Disposed = true })) + spyOn(consumerModule, 'consumeFirstServiceV4').andReturn(new Disposable(() => { firstServiceV4Disposed = true })) + spyOn(consumerModule, 'consumeSecondService').andReturn(new Disposable(() => { secondServiceDisposed = true })) + + await atom.packages.activatePackage('package-with-consumed-services') + await atom.packages.activatePackage('package-with-provided-services') + expect(consumerModule.consumeFirstServiceV3.callCount).toBe(1) + expect(consumerModule.consumeFirstServiceV3).toHaveBeenCalledWith('first-service-v3') + expect(consumerModule.consumeFirstServiceV4).toHaveBeenCalledWith('first-service-v4') + expect(consumerModule.consumeSecondService).toHaveBeenCalledWith('second-service') + + consumerModule.consumeFirstServiceV3.reset() + consumerModule.consumeFirstServiceV4.reset() + consumerModule.consumeSecondService.reset() + + await atom.packages.deactivatePackage('package-with-provided-services') + expect(firstServiceV3Disposed).toBe(true) + expect(firstServiceV4Disposed).toBe(true) + expect(secondServiceDisposed).toBe(true) + + await atom.packages.deactivatePackage('package-with-consumed-services') + await atom.packages.activatePackage('package-with-provided-services') + expect(consumerModule.consumeFirstServiceV3).not.toHaveBeenCalled() + expect(consumerModule.consumeFirstServiceV4).not.toHaveBeenCalled() + expect(consumerModule.consumeSecondService).not.toHaveBeenCalled() + }) + + it('ignores provided and consumed services that do not exist', async () => { + const addErrorHandler = jasmine.createSpy() + atom.notifications.onDidAddNotification(addErrorHandler) + + await atom.packages.activatePackage('package-with-missing-consumed-services') + await atom.packages.activatePackage('package-with-missing-provided-services') + expect(atom.packages.isPackageActive('package-with-missing-consumed-services')).toBe(true) + expect(atom.packages.isPackageActive('package-with-missing-provided-services')).toBe(true) + expect(addErrorHandler.callCount).toBe(0) + }) + }) + }) + + describe('::serialize', () => { + it('does not serialize packages that threw an error during activation', async () => { + spyOn(atom, 'inSpecMode').andReturn(false) + spyOn(console, 'warn') + + const badPack = await atom.packages.activatePackage('package-that-throws-on-activate') + spyOn(badPack.mainModule, 'serialize').andCallThrough() + + atom.packages.serialize() + expect(badPack.mainModule.serialize).not.toHaveBeenCalled() + }) + + it("absorbs exceptions that are thrown by the package module's serialize method", async () => { + spyOn(console, 'error') + + await atom.packages.activatePackage('package-with-serialize-error') + await atom.packages.activatePackage('package-with-serialization') + atom.packages.serialize() + expect(atom.packages.packageStates['package-with-serialize-error']).toBeUndefined() + expect(atom.packages.packageStates['package-with-serialization']).toEqual({someNumber: 1}) + expect(console.error).toHaveBeenCalled() + }) + }) + + describe('::deactivatePackages()', () => { + it('deactivates all packages but does not serialize them', async () => { + const pack1 = await atom.packages.activatePackage('package-with-deactivate') + const pack2 = await atom.packages.activatePackage('package-with-serialization') + + spyOn(pack1.mainModule, 'deactivate') + spyOn(pack2.mainModule, 'serialize') + await atom.packages.deactivatePackages() + expect(pack1.mainModule.deactivate).toHaveBeenCalled() + expect(pack2.mainModule.serialize).not.toHaveBeenCalled() + }) + }) + + describe('::deactivatePackage(id)', () => { + afterEach(() => atom.packages.unloadPackages()) + + it("calls `deactivate` on the package's main module if activate was successful", async () => { + spyOn(atom, 'inSpecMode').andReturn(false) + + const pack = await atom.packages.activatePackage('package-with-deactivate') + expect(atom.packages.isPackageActive('package-with-deactivate')).toBeTruthy() + spyOn(pack.mainModule, 'deactivate').andCallThrough() + + await atom.packages.deactivatePackage('package-with-deactivate') + expect(pack.mainModule.deactivate).toHaveBeenCalled() + expect(atom.packages.isPackageActive('package-with-module')).toBeFalsy() + + spyOn(console, 'warn') + const badPack = await atom.packages.activatePackage('package-that-throws-on-activate') + expect(atom.packages.isPackageActive('package-that-throws-on-activate')).toBeTruthy() + spyOn(badPack.mainModule, 'deactivate').andCallThrough() + + await atom.packages.deactivatePackage('package-that-throws-on-activate') + expect(badPack.mainModule.deactivate).not.toHaveBeenCalled() + expect(atom.packages.isPackageActive('package-that-throws-on-activate')).toBeFalsy() + }) + + it("absorbs exceptions that are thrown by the package module's deactivate method", async () => { + spyOn(console, 'error') + await atom.packages.activatePackage('package-that-throws-on-deactivate') + await atom.packages.deactivatePackage('package-that-throws-on-deactivate') + expect(console.error).toHaveBeenCalled() + }) + + it("removes the package's grammars", async () => { + await atom.packages.activatePackage('package-with-grammars') + await atom.packages.deactivatePackage('package-with-grammars') + expect(atom.grammars.selectGrammar('a.alot').name).toBe('Null Grammar') + expect(atom.grammars.selectGrammar('a.alittle').name).toBe('Null Grammar') + }) + + it("removes the package's keymaps", async () => { + await atom.packages.activatePackage('package-with-keymaps') + await atom.packages.deactivatePackage('package-with-keymaps') + expect(atom.keymaps.findKeyBindings({keystrokes: 'ctrl-z', target: createTestElement('test-1')})).toHaveLength(0) + expect(atom.keymaps.findKeyBindings({keystrokes: 'ctrl-z', target: createTestElement('test-2')})).toHaveLength(0) + }) + + it("removes the package's stylesheets", async () => { + await atom.packages.activatePackage('package-with-styles') + await atom.packages.deactivatePackage('package-with-styles') + + const one = require.resolve('./fixtures/packages/package-with-style-sheets-manifest/styles/1.css') + const two = require.resolve('./fixtures/packages/package-with-style-sheets-manifest/styles/2.less') + const three = require.resolve('./fixtures/packages/package-with-style-sheets-manifest/styles/3.css') + expect(atom.themes.stylesheetElementForId(one)).not.toExist() + expect(atom.themes.stylesheetElementForId(two)).not.toExist() + expect(atom.themes.stylesheetElementForId(three)).not.toExist() + }) + + it("removes the package's scoped-properties", async () => { + await atom.packages.activatePackage('package-with-settings') + expect(atom.config.get('editor.increaseIndentPattern', {scope: ['.source.omg']})).toBe('^a') + + await atom.packages.deactivatePackage('package-with-settings') + expect(atom.config.get('editor.increaseIndentPattern', {scope: ['.source.omg']})).toBeUndefined() + }) + + it('invokes ::onDidDeactivatePackage listeners with the deactivated package', async () => { + await atom.packages.activatePackage('package-with-main') + + let deactivatedPackage + atom.packages.onDidDeactivatePackage(pack => { + deactivatedPackage = pack + }) + + await atom.packages.deactivatePackage('package-with-main') + expect(deactivatedPackage.name).toBe('package-with-main') + }) + }) + + describe('::activate()', () => { + beforeEach(() => { + spyOn(atom, 'inSpecMode').andReturn(false) + jasmine.snapshotDeprecations() + spyOn(console, 'warn') + atom.packages.loadPackages() + + const loadedPackages = atom.packages.getLoadedPackages() + expect(loadedPackages.length).toBeGreaterThan(0) + }) + + afterEach(async () => { + await atom.packages.deactivatePackages() + atom.packages.unloadPackages() + jasmine.restoreDeprecationsSnapshot() + }) + + it('sets hasActivatedInitialPackages', async () => { + spyOn(atom.styles, 'getUserStyleSheetPath').andReturn(null) + spyOn(atom.packages, 'activatePackages') + expect(atom.packages.hasActivatedInitialPackages()).toBe(false) + + await atom.packages.activate() + expect(atom.packages.hasActivatedInitialPackages()).toBe(true) + }) + + it('activates all the packages, and none of the themes', () => { + const packageActivator = spyOn(atom.packages, 'activatePackages') + const themeActivator = spyOn(atom.themes, 'activatePackages') + + atom.packages.activate() + + expect(packageActivator).toHaveBeenCalled() + expect(themeActivator).toHaveBeenCalled() + + const packages = packageActivator.mostRecentCall.args[0] + for (let pack of packages) { expect(['atom', 'textmate']).toContain(pack.getType()) } + + const themes = themeActivator.mostRecentCall.args[0] + themes.map((theme) => expect(['theme']).toContain(theme.getType())) + }) + + it('calls callbacks registered with ::onDidActivateInitialPackages', async () => { + const package1 = atom.packages.loadPackage('package-with-main') + const package2 = atom.packages.loadPackage('package-with-index') + const package3 = atom.packages.loadPackage('package-with-activation-commands') + spyOn(atom.packages, 'getLoadedPackages').andReturn([package1, package2, package3]) + spyOn(atom.themes, 'activatePackages') + + atom.packages.activate() + await new Promise(resolve => atom.packages.onDidActivateInitialPackages(resolve)) + + jasmine.unspy(atom.packages, 'getLoadedPackages') + expect(atom.packages.getActivePackages().includes(package1)).toBe(true) + expect(atom.packages.getActivePackages().includes(package2)).toBe(true) + expect(atom.packages.getActivePackages().includes(package3)).toBe(false) + }) + }) + + describe('::enablePackage(id) and ::disablePackage(id)', () => { + describe('with packages', () => { + it('enables a disabled package', async () => { + const packageName = 'package-with-main' + atom.config.pushAtKeyPath('core.disabledPackages', packageName) + atom.packages.observeDisabledPackages() + expect(atom.config.get('core.disabledPackages')).toContain(packageName) + + const pack = atom.packages.enablePackage(packageName) + await new Promise(resolve => atom.packages.onDidActivatePackage(resolve)) + + expect(atom.packages.getLoadedPackages()).toContain(pack) + expect(atom.packages.getActivePackages()).toContain(pack) + expect(atom.config.get('core.disabledPackages')).not.toContain(packageName) + }) + + it('disables an enabled package', async () => { + const packageName = 'package-with-main' + const pack = await atom.packages.activatePackage(packageName) + + atom.packages.observeDisabledPackages() + expect(atom.config.get('core.disabledPackages')).not.toContain(packageName) + await new Promise(resolve => { + atom.packages.onDidDeactivatePackage(resolve) + atom.packages.disablePackage(packageName) + }) + + expect(atom.packages.getActivePackages()).not.toContain(pack) + expect(atom.config.get('core.disabledPackages')).toContain(packageName) + }) + + it('returns null if the package cannot be loaded', () => { + spyOn(console, 'warn') + expect(atom.packages.enablePackage('this-doesnt-exist')).toBeNull() + expect(console.warn.callCount).toBe(1) + }) + + it('does not disable an already disabled package', () => { + const packageName = 'package-with-main' + atom.config.pushAtKeyPath('core.disabledPackages', packageName) + atom.packages.observeDisabledPackages() + expect(atom.config.get('core.disabledPackages')).toContain(packageName) + + atom.packages.disablePackage(packageName) + const packagesDisabled = atom.config.get('core.disabledPackages').filter(pack => pack === packageName) + expect(packagesDisabled.length).toEqual(1) + }) + }) + + describe('with themes', () => { + beforeEach(() => atom.themes.activateThemes()) + afterEach(() => atom.themes.deactivateThemes()) + + it('enables and disables a theme', async () => { + const packageName = 'theme-with-package-file' + expect(atom.config.get('core.themes')).not.toContain(packageName) + expect(atom.config.get('core.disabledPackages')).not.toContain(packageName) + + // enabling of theme + const pack = atom.packages.enablePackage(packageName) + await new Promise(resolve => atom.packages.onDidActivatePackage(resolve)) + expect(atom.packages.isPackageActive(packageName)).toBe(true) + expect(atom.config.get('core.themes')).toContain(packageName) + expect(atom.config.get('core.disabledPackages')).not.toContain(packageName) + + await new Promise(resolve => { + atom.themes.onDidChangeActiveThemes(resolve) + atom.packages.disablePackage(packageName) + }) + + expect(atom.packages.getActivePackages()).not.toContain(pack) + expect(atom.config.get('core.themes')).not.toContain(packageName) + expect(atom.config.get('core.themes')).not.toContain(packageName) + expect(atom.config.get('core.disabledPackages')).not.toContain(packageName) + }) + }) + }) +}) diff --git a/spec/pane-element-spec.coffee b/spec/pane-element-spec.coffee index ff7634734..af34681a6 100644 --- a/spec/pane-element-spec.coffee +++ b/spec/pane-element-spec.coffee @@ -113,6 +113,53 @@ describe "PaneElement", -> expect(paneElement.dataset.activeItemPath).toBeUndefined() expect(paneElement.dataset.activeItemName).toBeUndefined() + describe "when the path of the item changes", -> + [item1, item2] = [] + + beforeEach -> + item1 = document.createElement('div') + item1.path = '/foo/bar.txt' + item1.changePathCallbacks = [] + item1.setPath = (path) -> + @path = path + callback() for callback in @changePathCallbacks + return + item1.getPath = -> @path + item1.onDidChangePath = (callback) -> + @changePathCallbacks.push callback + return dispose: => + @changePathCallbacks = @changePathCallbacks.filter (f) -> f isnt callback + + item2 = document.createElement('div') + + pane.addItem(item1) + pane.addItem(item2) + + it "changes the file path and file name data attributes on the pane if the active item path is changed", -> + + expect(paneElement.dataset.activeItemPath).toBe '/foo/bar.txt' + expect(paneElement.dataset.activeItemName).toBe 'bar.txt' + + item1.setPath "/foo/bar1.txt" + + expect(paneElement.dataset.activeItemPath).toBe '/foo/bar1.txt' + expect(paneElement.dataset.activeItemName).toBe 'bar1.txt' + + pane.activateItem(item2) + + expect(paneElement.dataset.activeItemPath).toBeUndefined() + expect(paneElement.dataset.activeItemName).toBeUndefined() + + item1.setPath "/foo/bar2.txt" + + expect(paneElement.dataset.activeItemPath).toBeUndefined() + expect(paneElement.dataset.activeItemName).toBeUndefined() + + pane.activateItem(item1) + + expect(paneElement.dataset.activeItemPath).toBe '/foo/bar2.txt' + expect(paneElement.dataset.activeItemName).toBe 'bar2.txt' + describe "when an item is removed from the pane", -> describe "when the destroyed item is an element", -> it "removes the item from the itemViews div", -> diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 3fd40cdad..fa72e42ef 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -286,6 +286,31 @@ describe('TextEditorComponent', () => { expect(lineNumberNodeForScreenRow(component, 0).querySelector('.foldable')).toBeNull() }) + it('gracefully handles folds that change the soft-wrap boundary by causing the vertical scrollbar to disappear (regression)', async () => { + const text = ('x'.repeat(100) + '\n') + 'y\n'.repeat(28) + ' z\n'.repeat(50) + const {component, element, editor} = buildComponent({text, height: 1000, width: 500}) + + element.addEventListener('scroll', (event) => { + event.stopPropagation() + }, true) + + editor.setSoftWrapped(true) + jasmine.attachToDOM(element) + await component.getNextUpdatePromise() + + const firstScreenLineLengthWithVerticalScrollbar = element.querySelector('.line').textContent.length + + setScrollTop(component, 620) + await component.getNextUpdatePromise() + + editor.foldBufferRow(28) + await component.getNextUpdatePromise() + + const firstLineElement = element.querySelector('.line') + expect(firstLineElement.dataset.screenRow).toBe('0') + expect(firstLineElement.textContent.length).toBeGreaterThan(firstScreenLineLengthWithVerticalScrollbar) + }) + it('shows the foldable icon on the last screen row of a buffer row that can be folded', async () => { const {component, element, editor} = buildComponent({text: 'abc\n de\nfghijklm\n no', softWrapped: true}) await setEditorWidthInCharacters(component, 5) @@ -3343,9 +3368,9 @@ describe('TextEditorComponent', () => { await component.getNextUpdatePromise() expect(editor.isFoldedAtScreenRow(5)).toBe(true) - target = element.querySelectorAll('.line-number')[6].querySelector('.icon-right') - component.didMouseDownOnLineNumberGutter({target, button: 0, clientY: clientTopForLine(component, 5)}) - expect(editor.isFoldedAtScreenRow(5)).toBe(false) + target = element.querySelectorAll('.line-number')[4].querySelector('.icon-right') + component.didMouseDownOnLineNumberGutter({target, button: 0, clientY: clientTopForLine(component, 4)}) + expect(editor.isFoldedAtScreenRow(4)).toBe(false) }) it('autoscrolls when dragging near the top or bottom of the gutter', async () => { diff --git a/spec/text-editor-spec.coffee b/spec/text-editor-spec.coffee index cb70d030c..53011fdcc 100644 --- a/spec/text-editor-spec.coffee +++ b/spec/text-editor-spec.coffee @@ -1168,6 +1168,58 @@ describe "TextEditor", -> editor.setCursorBufferPosition([3, 1]) expect(editor.getCurrentParagraphBufferRange()).toBeUndefined() + it 'will limit paragraph range to comments', -> + waitsForPromise -> + atom.packages.activatePackage('language-javascript') + + runs -> + editor.setGrammar(atom.grammars.grammarForScopeName('source.js')) + editor.setText(""" + var quicksort = function () { + /* Single line comment block */ + var sort = function(items) {}; + + /* + A multiline + comment is here + */ + var sort = function(items) {}; + + // A comment + // + // Multiple comment + // lines + var sort = function(items) {}; + // comment line after fn + + var nosort = function(items) { + item; + } + + }; + """) + + paragraphBufferRangeForRow = (row) -> + editor.setCursorBufferPosition([row, 0]) + editor.getLastCursor().getCurrentParagraphBufferRange() + + expect(paragraphBufferRangeForRow(0)).toEqual([[0, 0], [0, 29]]) + expect(paragraphBufferRangeForRow(1)).toEqual([[1, 0], [1, 33]]) + expect(paragraphBufferRangeForRow(2)).toEqual([[2, 0], [2, 32]]) + expect(paragraphBufferRangeForRow(3)).toBeFalsy() + expect(paragraphBufferRangeForRow(4)).toEqual([[4, 0], [7, 4]]) + expect(paragraphBufferRangeForRow(5)).toEqual([[4, 0], [7, 4]]) + expect(paragraphBufferRangeForRow(6)).toEqual([[4, 0], [7, 4]]) + expect(paragraphBufferRangeForRow(7)).toEqual([[4, 0], [7, 4]]) + expect(paragraphBufferRangeForRow(8)).toEqual([[8, 0], [8, 32]]) + expect(paragraphBufferRangeForRow(9)).toBeFalsy() + expect(paragraphBufferRangeForRow(10)).toEqual([[10, 0], [13, 10]]) + expect(paragraphBufferRangeForRow(11)).toEqual([[10, 0], [13, 10]]) + expect(paragraphBufferRangeForRow(12)).toEqual([[10, 0], [13, 10]]) + expect(paragraphBufferRangeForRow(14)).toEqual([[14, 0], [14, 32]]) + expect(paragraphBufferRangeForRow(15)).toEqual([[15, 0], [15, 26]]) + expect(paragraphBufferRangeForRow(18)).toEqual([[17, 0], [19, 3]]) + describe "getCursorAtScreenPosition(screenPosition)", -> it "returns the cursor at the given screenPosition", -> cursor1 = editor.addCursorAtScreenPosition([0, 2]) @@ -5272,37 +5324,6 @@ describe "TextEditor", -> [[6, 3], [6, 4]], ]) - describe ".shouldPromptToSave()", -> - it "returns true when buffer changed", -> - jasmine.unspy(editor, 'shouldPromptToSave') - expect(editor.shouldPromptToSave()).toBeFalsy() - buffer.setText('changed') - expect(editor.shouldPromptToSave()).toBeTruthy() - - it "returns false when an edit session's buffer is in use by more than one session", -> - jasmine.unspy(editor, 'shouldPromptToSave') - buffer.setText('changed') - - editor2 = null - waitsForPromise -> - atom.workspace.getActivePane().splitRight() - atom.workspace.open('sample.js', autoIndent: false).then (o) -> editor2 = o - - runs -> - expect(editor.shouldPromptToSave()).toBeFalsy() - editor2.destroy() - expect(editor.shouldPromptToSave()).toBeTruthy() - - it "returns false when close of a window requested and edit session opened inside project", -> - jasmine.unspy(editor, 'shouldPromptToSave') - buffer.setText('changed') - expect(editor.shouldPromptToSave(windowCloseRequested: true, projectHasPaths: true)).toBeFalsy() - - it "returns true when close of a window requested and edit session opened without project", -> - jasmine.unspy(editor, 'shouldPromptToSave') - buffer.setText('changed') - expect(editor.shouldPromptToSave(windowCloseRequested: true, projectHasPaths: false)).toBeTruthy() - describe "when the editor contains surrogate pair characters", -> it "correctly backspaces over them", -> editor.setText('\uD835\uDF97\uD835\uDF97\uD835\uDF97') diff --git a/spec/text-editor-spec.js b/spec/text-editor-spec.js new file mode 100644 index 000000000..c81df8089 --- /dev/null +++ b/spec/text-editor-spec.js @@ -0,0 +1,249 @@ +const fs = require('fs') +const temp = require('temp').track() +const {Point, Range} = require('text-buffer') +const {it, fit, ffit, fffit, beforeEach, afterEach} = require('./async-spec-helpers') + +describe('TextEditor', () => { + let editor + + afterEach(() => { + editor.destroy() + }) + + describe('.shouldPromptToSave()', () => { + beforeEach(async () => { + editor = await atom.workspace.open('sample.js') + jasmine.unspy(editor, 'shouldPromptToSave') + }) + + it('returns true when buffer has unsaved changes', () => { + expect(editor.shouldPromptToSave()).toBeFalsy() + editor.setText('changed') + expect(editor.shouldPromptToSave()).toBeTruthy() + }) + + it("returns false when an editor's buffer is in use by more than one buffer", async () => { + editor.setText('changed') + + atom.workspace.getActivePane().splitRight() + const editor2 = await atom.workspace.open('sample.js', {autoIndent: false}) + expect(editor.shouldPromptToSave()).toBeFalsy() + + editor2.destroy() + expect(editor.shouldPromptToSave()).toBeTruthy() + }) + + it('returns true when the window is closing if the file has changed on disk', async () => { + jasmine.useRealClock() + + editor.setText('initial stuff') + await editor.saveAs(temp.openSync('test-file').path) + + editor.setText('other stuff') + fs.writeFileSync(editor.getPath(), 'new stuff') + expect(editor.shouldPromptToSave({windowCloseRequested: true, projectHasPaths: true})).toBeFalsy() + + await new Promise(resolve => editor.onDidConflict(resolve)) + expect(editor.shouldPromptToSave({windowCloseRequested: true, projectHasPaths: true})).toBeTruthy() + }) + + it('returns false when the window is closing and the project has one or more directory paths', () => { + editor.setText('changed') + expect(editor.shouldPromptToSave({windowCloseRequested: true, projectHasPaths: true})).toBeFalsy() + }) + + it('returns false when the window is closing and the project has no directory paths', () => { + editor.setText('changed') + expect(editor.shouldPromptToSave({windowCloseRequested: true, projectHasPaths: false})).toBeTruthy() + }) + }) + + describe('folding', () => { + beforeEach(async () => { + await atom.packages.activatePackage('language-javascript') + }) + + it('maintains cursor buffer position when a folding/unfolding', async () => { + editor = await atom.workspace.open('sample.js', {autoIndent: false}) + editor.setCursorBufferPosition([5, 5]) + editor.foldAll() + expect(editor.getCursorBufferPosition()).toEqual([5, 5]) + }) + + describe('.unfoldAll()', () => { + it('unfolds every folded line', async () => { + editor = await atom.workspace.open('sample.js', {autoIndent: false}) + + const initialScreenLineCount = editor.getScreenLineCount() + editor.foldBufferRow(0) + editor.foldBufferRow(1) + expect(editor.getScreenLineCount()).toBeLessThan(initialScreenLineCount) + editor.unfoldAll() + expect(editor.getScreenLineCount()).toBe(initialScreenLineCount) + }) + + it('unfolds every folded line with comments', async () => { + editor = await atom.workspace.open('sample-with-comments.js', {autoIndent: false}) + + const initialScreenLineCount = editor.getScreenLineCount() + editor.foldBufferRow(0) + editor.foldBufferRow(5) + expect(editor.getScreenLineCount()).toBeLessThan(initialScreenLineCount) + editor.unfoldAll() + expect(editor.getScreenLineCount()).toBe(initialScreenLineCount) + }) + }) + + describe('.foldAll()', () => { + it('folds every foldable line', async () => { + editor = await atom.workspace.open('sample.js', {autoIndent: false}) + + editor.foldAll() + const [fold1, fold2, fold3] = editor.unfoldAll() + expect([fold1.start.row, fold1.end.row]).toEqual([0, 12]) + expect([fold2.start.row, fold2.end.row]).toEqual([1, 9]) + expect([fold3.start.row, fold3.end.row]).toEqual([4, 7]) + }) + + it('works with multi-line comments', async () => { + editor = await atom.workspace.open('sample-with-comments.js', {autoIndent: false}) + + editor.foldAll() + const folds = editor.unfoldAll() + expect(folds.length).toBe(8) + expect([folds[0].start.row, folds[0].end.row]).toEqual([0, 30]) + expect([folds[1].start.row, folds[1].end.row]).toEqual([1, 4]) + expect([folds[2].start.row, folds[2].end.row]).toEqual([5, 27]) + expect([folds[3].start.row, folds[3].end.row]).toEqual([6, 8]) + expect([folds[4].start.row, folds[4].end.row]).toEqual([11, 16]) + expect([folds[5].start.row, folds[5].end.row]).toEqual([17, 20]) + expect([folds[6].start.row, folds[6].end.row]).toEqual([21, 22]) + expect([folds[7].start.row, folds[7].end.row]).toEqual([24, 25]) + }) + }) + + describe('.foldBufferRow(bufferRow)', () => { + beforeEach(async () => { + editor = await atom.workspace.open('sample.js') + }) + + describe('when bufferRow can be folded', () => { + it('creates a fold based on the syntactic region starting at the given row', () => { + editor.foldBufferRow(1) + const [fold] = editor.unfoldAll() + expect([fold.start.row, fold.end.row]).toEqual([1, 9]) + }) + }) + + describe("when bufferRow can't be folded", () => { + it('searches upward for the first row that begins a syntactic region containing the given buffer row (and folds it)', () => { + editor.foldBufferRow(8) + const [fold] = editor.unfoldAll() + expect([fold.start.row, fold.end.row]).toEqual([1, 9]) + }) + }) + + describe('when the bufferRow is already folded', () => { + it('searches upward for the first row that begins a syntactic region containing the folded row (and folds it)', () => { + editor.foldBufferRow(2) + expect(editor.isFoldedAtBufferRow(0)).toBe(false) + expect(editor.isFoldedAtBufferRow(1)).toBe(true) + + editor.foldBufferRow(1) + expect(editor.isFoldedAtBufferRow(0)).toBe(true) + }) + }) + + describe('when the bufferRow is in a multi-line comment', () => { + it('searches upward and downward for surrounding comment lines and folds them as a single fold', () => { + editor.buffer.insert([1, 0], ' //this is a comment\n // and\n //more docs\n\n//second comment') + editor.foldBufferRow(1) + const [fold] = editor.unfoldAll() + expect([fold.start.row, fold.end.row]).toEqual([1, 3]) + }) + }) + + describe('when the bufferRow is a single-line comment', () => { + it('searches upward for the first row that begins a syntactic region containing the folded row (and folds it)', () => { + editor.buffer.insert([1, 0], ' //this is a single line comment\n') + editor.foldBufferRow(1) + const [fold] = editor.unfoldAll() + expect([fold.start.row, fold.end.row]).toEqual([0, 13]) + }) + }) + }) + + describe('.foldAllAtIndentLevel(indentLevel)', () => { + it('folds blocks of text at the given indentation level', async () => { + editor = await atom.workspace.open('sample.js', {autoIndent: false}) + + editor.foldAllAtIndentLevel(0) + expect(editor.lineTextForScreenRow(0)).toBe(`var quicksort = function () {${editor.displayLayer.foldCharacter}`) + expect(editor.getLastScreenRow()).toBe(0) + + editor.foldAllAtIndentLevel(1) + expect(editor.lineTextForScreenRow(0)).toBe('var quicksort = function () {') + expect(editor.lineTextForScreenRow(1)).toBe(` var sort = function(items) {${editor.displayLayer.foldCharacter}`) + expect(editor.getLastScreenRow()).toBe(4) + + editor.foldAllAtIndentLevel(2) + expect(editor.lineTextForScreenRow(0)).toBe('var quicksort = function () {') + expect(editor.lineTextForScreenRow(1)).toBe(' var sort = function(items) {') + expect(editor.lineTextForScreenRow(2)).toBe(' if (items.length <= 1) return items;') + expect(editor.getLastScreenRow()).toBe(9) + }) + + it('folds every foldable range at a given indentLevel', async () => { + editor = await atom.workspace.open('sample-with-comments.js', {autoIndent: false}) + + editor.foldAllAtIndentLevel(2) + const folds = editor.unfoldAll() + expect(folds.length).toBe(5) + expect([folds[0].start.row, folds[0].end.row]).toEqual([6, 8]) + expect([folds[1].start.row, folds[1].end.row]).toEqual([11, 16]) + expect([folds[2].start.row, folds[2].end.row]).toEqual([17, 20]) + expect([folds[3].start.row, folds[3].end.row]).toEqual([21, 22]) + expect([folds[4].start.row, folds[4].end.row]).toEqual([24, 25]) + }) + + it('does not fold anything but the indentLevel', async () => { + editor = await atom.workspace.open('sample-with-comments.js', {autoIndent: false}) + + editor.foldAllAtIndentLevel(0) + const folds = editor.unfoldAll() + expect(folds.length).toBe(1) + expect([folds[0].start.row, folds[0].end.row]).toEqual([0, 30]) + }) + }) + + describe('.isFoldableAtBufferRow(bufferRow)', () => { + it('returns true if the line starts a multi-line comment', async () => { + editor = await atom.workspace.open('sample-with-comments.js') + + expect(editor.isFoldableAtBufferRow(1)).toBe(true) + expect(editor.isFoldableAtBufferRow(6)).toBe(true) + expect(editor.isFoldableAtBufferRow(8)).toBe(false) + expect(editor.isFoldableAtBufferRow(11)).toBe(true) + expect(editor.isFoldableAtBufferRow(15)).toBe(false) + expect(editor.isFoldableAtBufferRow(17)).toBe(true) + expect(editor.isFoldableAtBufferRow(21)).toBe(true) + expect(editor.isFoldableAtBufferRow(24)).toBe(true) + expect(editor.isFoldableAtBufferRow(28)).toBe(false) + }) + + it('returns true for lines that end with a comment and are followed by an indented line', async () => { + editor = await atom.workspace.open('sample-with-comments.js') + + expect(editor.isFoldableAtBufferRow(5)).toBe(true) + }) + + it("does not return true for a line in the middle of a comment that's followed by an indented line", async () => { + editor = await atom.workspace.open('sample-with-comments.js') + + expect(editor.isFoldableAtBufferRow(7)).toBe(false) + editor.buffer.insert([8, 0], ' ') + expect(editor.isFoldableAtBufferRow(7)).toBe(false) + }) + }) + }) +}) diff --git a/spec/tokenized-buffer-spec.coffee b/spec/tokenized-buffer-spec.coffee deleted file mode 100644 index 2c2379810..000000000 --- a/spec/tokenized-buffer-spec.coffee +++ /dev/null @@ -1,688 +0,0 @@ -NullGrammar = require '../src/null-grammar' -TokenizedBuffer = require '../src/tokenized-buffer' -{Point} = TextBuffer = require 'text-buffer' -_ = require 'underscore-plus' - -describe "TokenizedBuffer", -> - [tokenizedBuffer, buffer] = [] - - beforeEach -> - # enable async tokenization - TokenizedBuffer.prototype.chunkSize = 5 - jasmine.unspy(TokenizedBuffer.prototype, 'tokenizeInBackground') - - waitsForPromise -> - atom.packages.activatePackage('language-javascript') - - afterEach -> - tokenizedBuffer?.destroy() - - startTokenizing = (tokenizedBuffer) -> - tokenizedBuffer.setVisible(true) - - fullyTokenize = (tokenizedBuffer) -> - tokenizedBuffer.setVisible(true) - advanceClock() while tokenizedBuffer.firstInvalidRow()? - - describe "serialization", -> - describe "when the underlying buffer has a path", -> - beforeEach -> - buffer = atom.project.bufferForPathSync('sample.js') - - waitsForPromise -> - atom.packages.activatePackage('language-coffee-script') - - it "deserializes it searching among the buffers in the current project", -> - tokenizedBufferA = new TokenizedBuffer({buffer, tabLength: 2}) - tokenizedBufferB = TokenizedBuffer.deserialize(JSON.parse(JSON.stringify(tokenizedBufferA.serialize())), atom) - expect(tokenizedBufferB.buffer).toBe(tokenizedBufferA.buffer) - - describe "when the underlying buffer has no path", -> - beforeEach -> - buffer = atom.project.bufferForPathSync(null) - - it "deserializes it searching among the buffers in the current project", -> - tokenizedBufferA = new TokenizedBuffer({buffer, tabLength: 2}) - tokenizedBufferB = TokenizedBuffer.deserialize(JSON.parse(JSON.stringify(tokenizedBufferA.serialize())), atom) - expect(tokenizedBufferB.buffer).toBe(tokenizedBufferA.buffer) - - describe "when the buffer is destroyed", -> - beforeEach -> - buffer = atom.project.bufferForPathSync('sample.js') - tokenizedBuffer = new TokenizedBuffer({buffer, grammar: atom.grammars.grammarForScopeName('source.js'), tabLength: 2}) - startTokenizing(tokenizedBuffer) - - it "stops tokenization", -> - tokenizedBuffer.destroy() - spyOn(tokenizedBuffer, 'tokenizeNextChunk') - advanceClock() - expect(tokenizedBuffer.tokenizeNextChunk).not.toHaveBeenCalled() - - describe "when the buffer contains soft-tabs", -> - beforeEach -> - buffer = atom.project.bufferForPathSync('sample.js') - tokenizedBuffer = new TokenizedBuffer({buffer, grammar: atom.grammars.grammarForScopeName('source.js'), tabLength: 2}) - startTokenizing(tokenizedBuffer) - - afterEach -> - tokenizedBuffer.destroy() - buffer.release() - - describe "on construction", -> - it "tokenizes lines chunk at a time in the background", -> - line0 = tokenizedBuffer.tokenizedLines[0] - expect(line0).toBeUndefined() - - line11 = tokenizedBuffer.tokenizedLines[11] - expect(line11).toBeUndefined() - - # tokenize chunk 1 - advanceClock() - expect(tokenizedBuffer.tokenizedLines[0].ruleStack?).toBeTruthy() - expect(tokenizedBuffer.tokenizedLines[4].ruleStack?).toBeTruthy() - expect(tokenizedBuffer.tokenizedLines[5]).toBeUndefined() - - # tokenize chunk 2 - advanceClock() - expect(tokenizedBuffer.tokenizedLines[5].ruleStack?).toBeTruthy() - expect(tokenizedBuffer.tokenizedLines[9].ruleStack?).toBeTruthy() - expect(tokenizedBuffer.tokenizedLines[10]).toBeUndefined() - - # tokenize last chunk - advanceClock() - expect(tokenizedBuffer.tokenizedLines[10].ruleStack?).toBeTruthy() - expect(tokenizedBuffer.tokenizedLines[12].ruleStack?).toBeTruthy() - - describe "when the buffer is partially tokenized", -> - beforeEach -> - # tokenize chunk 1 only - advanceClock() - - describe "when there is a buffer change inside the tokenized region", -> - describe "when lines are added", -> - it "pushes the invalid rows down", -> - expect(tokenizedBuffer.firstInvalidRow()).toBe 5 - buffer.insert([1, 0], '\n\n') - expect(tokenizedBuffer.firstInvalidRow()).toBe 7 - - describe "when lines are removed", -> - it "pulls the invalid rows up", -> - expect(tokenizedBuffer.firstInvalidRow()).toBe 5 - buffer.delete([[1, 0], [3, 0]]) - expect(tokenizedBuffer.firstInvalidRow()).toBe 2 - - describe "when the change invalidates all the lines before the current invalid region", -> - it "retokenizes the invalidated lines and continues into the valid region", -> - expect(tokenizedBuffer.firstInvalidRow()).toBe 5 - buffer.insert([2, 0], '/*') - expect(tokenizedBuffer.firstInvalidRow()).toBe 3 - advanceClock() - expect(tokenizedBuffer.firstInvalidRow()).toBe 8 - - describe "when there is a buffer change surrounding an invalid row", -> - it "pushes the invalid row to the end of the change", -> - buffer.setTextInRange([[4, 0], [6, 0]], "\n\n\n") - expect(tokenizedBuffer.firstInvalidRow()).toBe 8 - - describe "when there is a buffer change inside an invalid region", -> - it "does not attempt to tokenize the lines in the change, and preserves the existing invalid row", -> - expect(tokenizedBuffer.firstInvalidRow()).toBe 5 - buffer.setTextInRange([[6, 0], [7, 0]], "\n\n\n") - expect(tokenizedBuffer.tokenizedLines[6]).toBeUndefined() - expect(tokenizedBuffer.tokenizedLines[7]).toBeUndefined() - expect(tokenizedBuffer.firstInvalidRow()).toBe 5 - - describe "when the buffer is fully tokenized", -> - beforeEach -> - fullyTokenize(tokenizedBuffer) - - describe "when there is a buffer change that is smaller than the chunk size", -> - describe "when lines are updated, but none are added or removed", -> - it "updates tokens to reflect the change", -> - buffer.setTextInRange([[0, 0], [2, 0]], "foo()\n7\n") - - expect(tokenizedBuffer.tokenizedLines[0].tokens[1]).toEqual(value: '(', scopes: ['source.js', 'meta.function-call.js', 'meta.arguments.js', 'punctuation.definition.arguments.begin.bracket.round.js']) - expect(tokenizedBuffer.tokenizedLines[1].tokens[0]).toEqual(value: '7', scopes: ['source.js', 'constant.numeric.decimal.js']) - # line 2 is unchanged - expect(tokenizedBuffer.tokenizedLines[2].tokens[1]).toEqual(value: 'if', scopes: ['source.js', 'keyword.control.js']) - - describe "when the change invalidates the tokenization of subsequent lines", -> - it "schedules the invalidated lines to be tokenized in the background", -> - buffer.insert([5, 30], '/* */') - buffer.insert([2, 0], '/*') - expect(tokenizedBuffer.tokenizedLines[3].tokens[0].scopes).toEqual ['source.js'] - - advanceClock() - expect(tokenizedBuffer.tokenizedLines[3].tokens[0].scopes).toEqual ['source.js', 'comment.block.js'] - expect(tokenizedBuffer.tokenizedLines[4].tokens[0].scopes).toEqual ['source.js', 'comment.block.js'] - expect(tokenizedBuffer.tokenizedLines[5].tokens[0].scopes).toEqual ['source.js', 'comment.block.js'] - - it "resumes highlighting with the state of the previous line", -> - buffer.insert([0, 0], '/*') - buffer.insert([5, 0], '*/') - - buffer.insert([1, 0], 'var ') - expect(tokenizedBuffer.tokenizedLines[1].tokens[0].scopes).toEqual ['source.js', 'comment.block.js'] - - describe "when lines are both updated and removed", -> - it "updates tokens to reflect the change", -> - buffer.setTextInRange([[1, 0], [3, 0]], "foo()") - - # previous line 0 remains - expect(tokenizedBuffer.tokenizedLines[0].tokens[0]).toEqual(value: 'var', scopes: ['source.js', 'storage.type.var.js']) - - # previous line 3 should be combined with input to form line 1 - expect(tokenizedBuffer.tokenizedLines[1].tokens[0]).toEqual(value: 'foo', scopes: ['source.js', 'meta.function-call.js', 'entity.name.function.js']) - expect(tokenizedBuffer.tokenizedLines[1].tokens[6]).toEqual(value: '=', scopes: ['source.js', 'keyword.operator.assignment.js']) - - # lines below deleted regions should be shifted upward - expect(tokenizedBuffer.tokenizedLines[2].tokens[1]).toEqual(value: 'while', scopes: ['source.js', 'keyword.control.js']) - expect(tokenizedBuffer.tokenizedLines[3].tokens[1]).toEqual(value: '=', scopes: ['source.js', 'keyword.operator.assignment.js']) - expect(tokenizedBuffer.tokenizedLines[4].tokens[1]).toEqual(value: '<', scopes: ['source.js', 'keyword.operator.comparison.js']) - - describe "when the change invalidates the tokenization of subsequent lines", -> - it "schedules the invalidated lines to be tokenized in the background", -> - buffer.insert([5, 30], '/* */') - buffer.setTextInRange([[2, 0], [3, 0]], '/*') - expect(tokenizedBuffer.tokenizedLines[2].tokens[0].scopes).toEqual ['source.js', 'comment.block.js', 'punctuation.definition.comment.begin.js'] - expect(tokenizedBuffer.tokenizedLines[3].tokens[0].scopes).toEqual ['source.js'] - - advanceClock() - expect(tokenizedBuffer.tokenizedLines[3].tokens[0].scopes).toEqual ['source.js', 'comment.block.js'] - expect(tokenizedBuffer.tokenizedLines[4].tokens[0].scopes).toEqual ['source.js', 'comment.block.js'] - - describe "when lines are both updated and inserted", -> - it "updates tokens to reflect the change", -> - buffer.setTextInRange([[1, 0], [2, 0]], "foo()\nbar()\nbaz()\nquux()") - - # previous line 0 remains - expect(tokenizedBuffer.tokenizedLines[0].tokens[0]).toEqual( value: 'var', scopes: ['source.js', 'storage.type.var.js']) - - # 3 new lines inserted - expect(tokenizedBuffer.tokenizedLines[1].tokens[0]).toEqual(value: 'foo', scopes: ['source.js', 'meta.function-call.js', 'entity.name.function.js']) - expect(tokenizedBuffer.tokenizedLines[2].tokens[0]).toEqual(value: 'bar', scopes: ['source.js', 'meta.function-call.js', 'entity.name.function.js']) - expect(tokenizedBuffer.tokenizedLines[3].tokens[0]).toEqual(value: 'baz', scopes: ['source.js', 'meta.function-call.js', 'entity.name.function.js']) - - # previous line 2 is joined with quux() on line 4 - expect(tokenizedBuffer.tokenizedLines[4].tokens[0]).toEqual(value: 'quux', scopes: ['source.js', 'meta.function-call.js', 'entity.name.function.js']) - expect(tokenizedBuffer.tokenizedLines[4].tokens[4]).toEqual(value: 'if', scopes: ['source.js', 'keyword.control.js']) - - # previous line 3 is pushed down to become line 5 - expect(tokenizedBuffer.tokenizedLines[5].tokens[3]).toEqual(value: '=', scopes: ['source.js', 'keyword.operator.assignment.js']) - - describe "when the change invalidates the tokenization of subsequent lines", -> - it "schedules the invalidated lines to be tokenized in the background", -> - buffer.insert([5, 30], '/* */') - buffer.insert([2, 0], '/*\nabcde\nabcder') - expect(tokenizedBuffer.tokenizedLines[2].tokens[0].scopes).toEqual ['source.js', 'comment.block.js', 'punctuation.definition.comment.begin.js'] - expect(tokenizedBuffer.tokenizedLines[3].tokens[0].scopes).toEqual ['source.js', 'comment.block.js'] - expect(tokenizedBuffer.tokenizedLines[4].tokens[0].scopes).toEqual ['source.js', 'comment.block.js'] - expect(tokenizedBuffer.tokenizedLines[5].tokens[0].scopes).toEqual ['source.js'] - - advanceClock() # tokenize invalidated lines in background - expect(tokenizedBuffer.tokenizedLines[5].tokens[0].scopes).toEqual ['source.js', 'comment.block.js'] - expect(tokenizedBuffer.tokenizedLines[6].tokens[0].scopes).toEqual ['source.js', 'comment.block.js'] - expect(tokenizedBuffer.tokenizedLines[7].tokens[0].scopes).toEqual ['source.js', 'comment.block.js'] - expect(tokenizedBuffer.tokenizedLines[8].tokens[0].scopes).not.toBe ['source.js', 'comment.block.js'] - - describe "when there is an insertion that is larger than the chunk size", -> - it "tokenizes the initial chunk synchronously, then tokenizes the remaining lines in the background", -> - commentBlock = _.multiplyString("// a comment\n", tokenizedBuffer.chunkSize + 2) - buffer.insert([0, 0], commentBlock) - expect(tokenizedBuffer.tokenizedLines[0].ruleStack?).toBeTruthy() - expect(tokenizedBuffer.tokenizedLines[4].ruleStack?).toBeTruthy() - expect(tokenizedBuffer.tokenizedLines[5]).toBeUndefined() - - advanceClock() - expect(tokenizedBuffer.tokenizedLines[5].ruleStack?).toBeTruthy() - expect(tokenizedBuffer.tokenizedLines[6].ruleStack?).toBeTruthy() - - it "does not break out soft tabs across a scope boundary", -> - waitsForPromise -> - atom.packages.activatePackage('language-gfm') - - runs -> - tokenizedBuffer.setTabLength(4) - tokenizedBuffer.setGrammar(atom.grammars.selectGrammar('.md')) - buffer.setText(' 0 - - expect(length).toBe 4 - - describe "when the buffer contains hard-tabs", -> - beforeEach -> - waitsForPromise -> - atom.packages.activatePackage('language-coffee-script') - - runs -> - buffer = atom.project.bufferForPathSync('sample-with-tabs.coffee') - tokenizedBuffer = new TokenizedBuffer({buffer, grammar: atom.grammars.grammarForScopeName('source.coffee'), tabLength: 2}) - startTokenizing(tokenizedBuffer) - - afterEach -> - tokenizedBuffer.destroy() - buffer.release() - - describe "when the buffer is fully tokenized", -> - beforeEach -> - fullyTokenize(tokenizedBuffer) - - describe "when the grammar is tokenized", -> - it "emits the `tokenized` event", -> - editor = null - tokenizedHandler = jasmine.createSpy("tokenized handler") - - waitsForPromise -> - atom.workspace.open('sample.js').then (o) -> editor = o - - runs -> - tokenizedBuffer = editor.tokenizedBuffer - tokenizedBuffer.onDidTokenize tokenizedHandler - fullyTokenize(tokenizedBuffer) - expect(tokenizedHandler.callCount).toBe(1) - - it "doesn't re-emit the `tokenized` event when it is re-tokenized", -> - editor = null - tokenizedHandler = jasmine.createSpy("tokenized handler") - - waitsForPromise -> - atom.workspace.open('sample.js').then (o) -> editor = o - - runs -> - tokenizedBuffer = editor.tokenizedBuffer - fullyTokenize(tokenizedBuffer) - - tokenizedBuffer.onDidTokenize tokenizedHandler - editor.getBuffer().insert([0, 0], "'") - fullyTokenize(tokenizedBuffer) - expect(tokenizedHandler).not.toHaveBeenCalled() - - describe "when the grammar is updated because a grammar it includes is activated", -> - it "re-emits the `tokenized` event", -> - editor = null - tokenizedBuffer = null - tokenizedHandler = jasmine.createSpy("tokenized handler") - - waitsForPromise -> - atom.workspace.open('coffee.coffee').then (o) -> editor = o - - runs -> - tokenizedBuffer = editor.tokenizedBuffer - tokenizedBuffer.onDidTokenize tokenizedHandler - fullyTokenize(tokenizedBuffer) - tokenizedHandler.reset() - - waitsForPromise -> - atom.packages.activatePackage('language-coffee-script') - - runs -> - fullyTokenize(tokenizedBuffer) - expect(tokenizedHandler.callCount).toBe(1) - - it "retokenizes the buffer", -> - waitsForPromise -> - atom.packages.activatePackage('language-ruby-on-rails') - - waitsForPromise -> - atom.packages.activatePackage('language-ruby') - - runs -> - buffer = atom.project.bufferForPathSync() - buffer.setText "
<%= User.find(2).full_name %>
" - tokenizedBuffer = new TokenizedBuffer({buffer, grammar: atom.grammars.selectGrammar('test.erb'), tabLength: 2}) - fullyTokenize(tokenizedBuffer) - - {tokens} = tokenizedBuffer.tokenizedLines[0] - expect(tokens[0]).toEqual value: "
", scopes: ["text.html.ruby"] - - waitsForPromise -> - atom.packages.activatePackage('language-html') - - runs -> - fullyTokenize(tokenizedBuffer) - {tokens} = tokenizedBuffer.tokenizedLines[0] - expect(tokens[0]).toEqual value: '<', scopes: ["text.html.ruby", "meta.tag.block.div.html", "punctuation.definition.tag.begin.html"] - - describe ".tokenForPosition(position)", -> - afterEach -> - tokenizedBuffer.destroy() - buffer.release() - - it "returns the correct token (regression)", -> - buffer = atom.project.bufferForPathSync('sample.js') - tokenizedBuffer = new TokenizedBuffer({buffer, grammar: atom.grammars.grammarForScopeName('source.js'), tabLength: 2}) - fullyTokenize(tokenizedBuffer) - expect(tokenizedBuffer.tokenForPosition([1, 0]).scopes).toEqual ["source.js"] - expect(tokenizedBuffer.tokenForPosition([1, 1]).scopes).toEqual ["source.js"] - expect(tokenizedBuffer.tokenForPosition([1, 2]).scopes).toEqual ["source.js", "storage.type.var.js"] - - describe ".bufferRangeForScopeAtPosition(selector, position)", -> - beforeEach -> - buffer = atom.project.bufferForPathSync('sample.js') - tokenizedBuffer = new TokenizedBuffer({buffer, grammar: atom.grammars.grammarForScopeName('source.js'), tabLength: 2}) - fullyTokenize(tokenizedBuffer) - - describe "when the selector does not match the token at the position", -> - it "returns a falsy value", -> - expect(tokenizedBuffer.bufferRangeForScopeAtPosition('.bogus', [0, 1])).toBeUndefined() - - describe "when the selector matches a single token at the position", -> - it "returns the range covered by the token", -> - expect(tokenizedBuffer.bufferRangeForScopeAtPosition('.storage.type.var.js', [0, 1])).toEqual [[0, 0], [0, 3]] - expect(tokenizedBuffer.bufferRangeForScopeAtPosition('.storage.type.var.js', [0, 3])).toEqual [[0, 0], [0, 3]] - - describe "when the selector matches a run of multiple tokens at the position", -> - it "returns the range covered by all contiguous tokens (within a single line)", -> - expect(tokenizedBuffer.bufferRangeForScopeAtPosition('.function', [1, 18])).toEqual [[1, 6], [1, 28]] - - describe ".indentLevelForRow(row)", -> - beforeEach -> - buffer = atom.project.bufferForPathSync('sample.js') - tokenizedBuffer = new TokenizedBuffer({buffer, grammar: atom.grammars.grammarForScopeName('source.js'), tabLength: 2}) - fullyTokenize(tokenizedBuffer) - - describe "when the line is non-empty", -> - it "has an indent level based on the leading whitespace on the line", -> - expect(tokenizedBuffer.indentLevelForRow(0)).toBe 0 - expect(tokenizedBuffer.indentLevelForRow(1)).toBe 1 - expect(tokenizedBuffer.indentLevelForRow(2)).toBe 2 - buffer.insert([2, 0], ' ') - expect(tokenizedBuffer.indentLevelForRow(2)).toBe 2.5 - - describe "when the line is empty", -> - it "assumes the indentation level of the first non-empty line below or above if one exists", -> - buffer.insert([12, 0], ' ') - buffer.insert([12, Infinity], '\n\n') - expect(tokenizedBuffer.indentLevelForRow(13)).toBe 2 - expect(tokenizedBuffer.indentLevelForRow(14)).toBe 2 - - buffer.insert([1, Infinity], '\n\n') - expect(tokenizedBuffer.indentLevelForRow(2)).toBe 2 - expect(tokenizedBuffer.indentLevelForRow(3)).toBe 2 - - buffer.setText('\n\n\n') - expect(tokenizedBuffer.indentLevelForRow(1)).toBe 0 - - describe "when the changed lines are surrounded by whitespace-only lines", -> - it "updates the indentLevel of empty lines that precede the change", -> - expect(tokenizedBuffer.indentLevelForRow(12)).toBe 0 - - buffer.insert([12, 0], '\n') - buffer.insert([13, 0], ' ') - expect(tokenizedBuffer.indentLevelForRow(12)).toBe 1 - - it "updates empty line indent guides when the empty line is the last line", -> - buffer.insert([12, 2], '\n') - - # The newline and the tab need to be in two different operations to surface the bug - buffer.insert([12, 0], ' ') - expect(tokenizedBuffer.indentLevelForRow(13)).toBe 1 - - buffer.insert([12, 0], ' ') - expect(tokenizedBuffer.indentLevelForRow(13)).toBe 2 - expect(tokenizedBuffer.tokenizedLines[14]).not.toBeDefined() - - it "updates the indentLevel of empty lines surrounding a change that inserts lines", -> - buffer.insert([7, 0], '\n\n') - buffer.insert([5, 0], '\n\n') - expect(tokenizedBuffer.indentLevelForRow(5)).toBe 3 - expect(tokenizedBuffer.indentLevelForRow(6)).toBe 3 - expect(tokenizedBuffer.indentLevelForRow(9)).toBe 3 - expect(tokenizedBuffer.indentLevelForRow(10)).toBe 3 - expect(tokenizedBuffer.indentLevelForRow(11)).toBe 2 - - buffer.setTextInRange([[7, 0], [8, 65]], ' one\n two\n three\n four') - expect(tokenizedBuffer.indentLevelForRow(5)).toBe 4 - expect(tokenizedBuffer.indentLevelForRow(6)).toBe 4 - expect(tokenizedBuffer.indentLevelForRow(11)).toBe 4 - expect(tokenizedBuffer.indentLevelForRow(12)).toBe 4 - expect(tokenizedBuffer.indentLevelForRow(13)).toBe 2 - - it "updates the indentLevel of empty lines surrounding a change that removes lines", -> - buffer.insert([7, 0], '\n\n') - buffer.insert([5, 0], '\n\n') - buffer.setTextInRange([[7, 0], [8, 65]], ' ok') - expect(tokenizedBuffer.indentLevelForRow(5)).toBe 2 - expect(tokenizedBuffer.indentLevelForRow(6)).toBe 2 - expect(tokenizedBuffer.indentLevelForRow(7)).toBe 2 # new text - expect(tokenizedBuffer.indentLevelForRow(8)).toBe 2 - expect(tokenizedBuffer.indentLevelForRow(9)).toBe 2 - expect(tokenizedBuffer.indentLevelForRow(10)).toBe 2 # } - - describe "::isFoldableAtRow(row)", -> - beforeEach -> - buffer = atom.project.bufferForPathSync('sample.js') - buffer.insert [10, 0], " // multi-line\n // comment\n // block\n" - buffer.insert [0, 0], "// multi-line\n// comment\n// block\n" - tokenizedBuffer = new TokenizedBuffer({buffer, grammar: atom.grammars.grammarForScopeName('source.js'), tabLength: 2}) - fullyTokenize(tokenizedBuffer) - - it "includes the first line of multi-line comments", -> - expect(tokenizedBuffer.isFoldableAtRow(0)).toBe true - expect(tokenizedBuffer.isFoldableAtRow(1)).toBe false - expect(tokenizedBuffer.isFoldableAtRow(2)).toBe false - expect(tokenizedBuffer.isFoldableAtRow(3)).toBe true # because of indent - expect(tokenizedBuffer.isFoldableAtRow(13)).toBe true - expect(tokenizedBuffer.isFoldableAtRow(14)).toBe false - expect(tokenizedBuffer.isFoldableAtRow(15)).toBe false - expect(tokenizedBuffer.isFoldableAtRow(16)).toBe false - - buffer.insert([0, Infinity], '\n') - - expect(tokenizedBuffer.isFoldableAtRow(0)).toBe false - expect(tokenizedBuffer.isFoldableAtRow(1)).toBe false - expect(tokenizedBuffer.isFoldableAtRow(2)).toBe true - expect(tokenizedBuffer.isFoldableAtRow(3)).toBe false - - buffer.undo() - - expect(tokenizedBuffer.isFoldableAtRow(0)).toBe true - expect(tokenizedBuffer.isFoldableAtRow(1)).toBe false - expect(tokenizedBuffer.isFoldableAtRow(2)).toBe false - expect(tokenizedBuffer.isFoldableAtRow(3)).toBe true # because of indent - - it "includes non-comment lines that precede an increase in indentation", -> - buffer.insert([2, 0], ' ') # commented lines preceding an indent aren't foldable - - expect(tokenizedBuffer.isFoldableAtRow(1)).toBe false - expect(tokenizedBuffer.isFoldableAtRow(2)).toBe false - expect(tokenizedBuffer.isFoldableAtRow(3)).toBe true - expect(tokenizedBuffer.isFoldableAtRow(4)).toBe true - expect(tokenizedBuffer.isFoldableAtRow(5)).toBe false - expect(tokenizedBuffer.isFoldableAtRow(6)).toBe false - expect(tokenizedBuffer.isFoldableAtRow(7)).toBe true - expect(tokenizedBuffer.isFoldableAtRow(8)).toBe false - - buffer.insert([7, 0], ' ') - - expect(tokenizedBuffer.isFoldableAtRow(6)).toBe true - expect(tokenizedBuffer.isFoldableAtRow(7)).toBe false - expect(tokenizedBuffer.isFoldableAtRow(8)).toBe false - - buffer.undo() - - expect(tokenizedBuffer.isFoldableAtRow(6)).toBe false - expect(tokenizedBuffer.isFoldableAtRow(7)).toBe true - expect(tokenizedBuffer.isFoldableAtRow(8)).toBe false - - buffer.insert([7, 0], " \n x\n") - - expect(tokenizedBuffer.isFoldableAtRow(6)).toBe true - expect(tokenizedBuffer.isFoldableAtRow(7)).toBe false - expect(tokenizedBuffer.isFoldableAtRow(8)).toBe false - - buffer.insert([9, 0], " ") - - expect(tokenizedBuffer.isFoldableAtRow(6)).toBe true - expect(tokenizedBuffer.isFoldableAtRow(7)).toBe false - expect(tokenizedBuffer.isFoldableAtRow(8)).toBe false - - describe "::tokenizedLineForRow(row)", -> - it "returns the tokenized line for a row, or a placeholder line if it hasn't been tokenized yet", -> - buffer = atom.project.bufferForPathSync('sample.js') - grammar = atom.grammars.grammarForScopeName('source.js') - tokenizedBuffer = new TokenizedBuffer({buffer, grammar, tabLength: 2}) - line0 = buffer.lineForRow(0) - - jsScopeStartId = grammar.startIdForScope(grammar.scopeName) - jsScopeEndId = grammar.endIdForScope(grammar.scopeName) - startTokenizing(tokenizedBuffer) - expect(tokenizedBuffer.tokenizedLines[0]).toBeUndefined() - expect(tokenizedBuffer.tokenizedLineForRow(0).text).toBe(line0) - expect(tokenizedBuffer.tokenizedLineForRow(0).tags).toEqual([jsScopeStartId, line0.length, jsScopeEndId]) - advanceClock(1) - expect(tokenizedBuffer.tokenizedLines[0]).not.toBeUndefined() - expect(tokenizedBuffer.tokenizedLineForRow(0).text).toBe(line0) - expect(tokenizedBuffer.tokenizedLineForRow(0).tags).not.toEqual([jsScopeStartId, line0.length, jsScopeEndId]) - - nullScopeStartId = NullGrammar.startIdForScope(NullGrammar.scopeName) - nullScopeEndId = NullGrammar.endIdForScope(NullGrammar.scopeName) - tokenizedBuffer.setGrammar(NullGrammar) - startTokenizing(tokenizedBuffer) - expect(tokenizedBuffer.tokenizedLines[0]).toBeUndefined() - expect(tokenizedBuffer.tokenizedLineForRow(0).text).toBe(line0) - expect(tokenizedBuffer.tokenizedLineForRow(0).tags).toEqual([nullScopeStartId, line0.length, nullScopeEndId]) - advanceClock(1) - expect(tokenizedBuffer.tokenizedLineForRow(0).text).toBe(line0) - expect(tokenizedBuffer.tokenizedLineForRow(0).tags).toEqual([nullScopeStartId, line0.length, nullScopeEndId]) - - it "returns undefined if the requested row is outside the buffer range", -> - buffer = atom.project.bufferForPathSync('sample.js') - grammar = atom.grammars.grammarForScopeName('source.js') - tokenizedBuffer = new TokenizedBuffer({buffer, grammar, tabLength: 2}) - fullyTokenize(tokenizedBuffer) - expect(tokenizedBuffer.tokenizedLineForRow(999)).toBeUndefined() - - describe "when the buffer is configured with the null grammar", -> - it "does not actually tokenize using the grammar", -> - spyOn(NullGrammar, 'tokenizeLine').andCallThrough() - buffer = atom.project.bufferForPathSync('sample.will-use-the-null-grammar') - buffer.setText('a\nb\nc') - tokenizedBuffer = new TokenizedBuffer({buffer, tabLength: 2}) - tokenizeCallback = jasmine.createSpy('onDidTokenize') - tokenizedBuffer.onDidTokenize(tokenizeCallback) - - expect(tokenizedBuffer.tokenizedLines[0]).toBeUndefined() - expect(tokenizedBuffer.tokenizedLines[1]).toBeUndefined() - expect(tokenizedBuffer.tokenizedLines[2]).toBeUndefined() - expect(tokenizeCallback.callCount).toBe(0) - expect(NullGrammar.tokenizeLine).not.toHaveBeenCalled() - - fullyTokenize(tokenizedBuffer) - expect(tokenizedBuffer.tokenizedLines[0]).toBeUndefined() - expect(tokenizedBuffer.tokenizedLines[1]).toBeUndefined() - expect(tokenizedBuffer.tokenizedLines[2]).toBeUndefined() - expect(tokenizeCallback.callCount).toBe(0) - expect(NullGrammar.tokenizeLine).not.toHaveBeenCalled() - - describe "text decoration layer API", -> - describe "iterator", -> - it "iterates over the syntactic scope boundaries", -> - buffer = new TextBuffer(text: "var foo = 1 /*\nhello*/var bar = 2\n") - tokenizedBuffer = new TokenizedBuffer({buffer, grammar: atom.grammars.grammarForScopeName("source.js"), tabLength: 2}) - fullyTokenize(tokenizedBuffer) - - iterator = tokenizedBuffer.buildIterator() - iterator.seek(Point(0, 0)) - - expectedBoundaries = [ - {position: Point(0, 0), closeTags: [], openTags: ["syntax--source syntax--js", "syntax--storage syntax--type syntax--var syntax--js"]} - {position: Point(0, 3), closeTags: ["syntax--storage syntax--type syntax--var syntax--js"], openTags: []} - {position: Point(0, 8), closeTags: [], openTags: ["syntax--keyword syntax--operator syntax--assignment syntax--js"]} - {position: Point(0, 9), closeTags: ["syntax--keyword syntax--operator syntax--assignment syntax--js"], openTags: []} - {position: Point(0, 10), closeTags: [], openTags: ["syntax--constant syntax--numeric syntax--decimal syntax--js"]} - {position: Point(0, 11), closeTags: ["syntax--constant syntax--numeric syntax--decimal syntax--js"], openTags: []} - {position: Point(0, 12), closeTags: [], openTags: ["syntax--comment syntax--block syntax--js", "syntax--punctuation syntax--definition syntax--comment syntax--begin syntax--js"]} - {position: Point(0, 14), closeTags: ["syntax--punctuation syntax--definition syntax--comment syntax--begin syntax--js"], openTags: []} - {position: Point(1, 5), closeTags: [], openTags: ["syntax--punctuation syntax--definition syntax--comment syntax--end syntax--js"]} - {position: Point(1, 7), closeTags: ["syntax--punctuation syntax--definition syntax--comment syntax--end syntax--js", "syntax--comment syntax--block syntax--js"], openTags: ["syntax--storage syntax--type syntax--var syntax--js"]} - {position: Point(1, 10), closeTags: ["syntax--storage syntax--type syntax--var syntax--js"], openTags: []} - {position: Point(1, 15), closeTags: [], openTags: ["syntax--keyword syntax--operator syntax--assignment syntax--js"]} - {position: Point(1, 16), closeTags: ["syntax--keyword syntax--operator syntax--assignment syntax--js"], openTags: []} - {position: Point(1, 17), closeTags: [], openTags: ["syntax--constant syntax--numeric syntax--decimal syntax--js"]} - {position: Point(1, 18), closeTags: ["syntax--constant syntax--numeric syntax--decimal syntax--js"], openTags: []} - ] - - loop - boundary = { - position: iterator.getPosition(), - closeTags: iterator.getCloseScopeIds().map((scopeId) -> tokenizedBuffer.classNameForScopeId(scopeId)), - openTags: iterator.getOpenScopeIds().map((scopeId) -> tokenizedBuffer.classNameForScopeId(scopeId)) - } - - expect(boundary).toEqual(expectedBoundaries.shift()) - break unless iterator.moveToSuccessor() - - expect(iterator.seek(Point(0, 1)).map((scopeId) -> tokenizedBuffer.classNameForScopeId(scopeId))).toEqual([ - "syntax--source syntax--js", - "syntax--storage syntax--type syntax--var syntax--js" - ]) - expect(iterator.getPosition()).toEqual(Point(0, 3)) - expect(iterator.seek(Point(0, 8)).map((scopeId) -> tokenizedBuffer.classNameForScopeId(scopeId))).toEqual([ - "syntax--source syntax--js" - ]) - expect(iterator.getPosition()).toEqual(Point(0, 8)) - expect(iterator.seek(Point(1, 0)).map((scopeId) -> tokenizedBuffer.classNameForScopeId(scopeId))).toEqual([ - "syntax--source syntax--js", - "syntax--comment syntax--block syntax--js" - ]) - expect(iterator.getPosition()).toEqual(Point(1, 0)) - expect(iterator.seek(Point(1, 18)).map((scopeId) -> tokenizedBuffer.classNameForScopeId(scopeId))).toEqual([ - "syntax--source syntax--js", - "syntax--constant syntax--numeric syntax--decimal syntax--js" - ]) - expect(iterator.getPosition()).toEqual(Point(1, 18)) - - expect(iterator.seek(Point(2, 0)).map((scopeId) -> tokenizedBuffer.classNameForScopeId(scopeId))).toEqual([ - "syntax--source syntax--js" - ]) - iterator.moveToSuccessor() # ensure we don't infinitely loop (regression test) - - it "does not report columns beyond the length of the line", -> - waitsForPromise -> - atom.packages.activatePackage('language-coffee-script') - - runs -> - buffer = new TextBuffer(text: "# hello\n# world") - tokenizedBuffer = new TokenizedBuffer({buffer, grammar: atom.grammars.grammarForScopeName("source.coffee"), tabLength: 2}) - fullyTokenize(tokenizedBuffer) - - iterator = tokenizedBuffer.buildIterator() - iterator.seek(Point(0, 0)) - iterator.moveToSuccessor() - iterator.moveToSuccessor() - expect(iterator.getPosition().column).toBe(7) - - iterator.moveToSuccessor() - expect(iterator.getPosition().column).toBe(0) - - iterator.seek(Point(0, 7)) - expect(iterator.getPosition().column).toBe(7) - - iterator.seek(Point(0, 8)) - expect(iterator.getPosition().column).toBe(7) - - it "correctly terminates scopes at the beginning of the line (regression)", -> - grammar = atom.grammars.createGrammar('test', { - 'scopeName': 'text.broken' - 'name': 'Broken grammar' - 'patterns': [ - {'begin': 'start', 'end': '(?=end)', 'name': 'blue.broken'}, - {'match': '.', 'name': 'yellow.broken'} - ] - }) - - buffer = new TextBuffer(text: 'start x\nend x\nx') - tokenizedBuffer = new TokenizedBuffer({buffer, grammar, tabLength: 2}) - fullyTokenize(tokenizedBuffer) - - iterator = tokenizedBuffer.buildIterator() - iterator.seek(Point(1, 0)) - - expect(iterator.getPosition()).toEqual([1, 0]) - expect(iterator.getCloseScopeIds().map((scopeId) -> tokenizedBuffer.classNameForScopeId(scopeId))).toEqual ['syntax--blue syntax--broken'] - expect(iterator.getOpenScopeIds().map((scopeId) -> tokenizedBuffer.classNameForScopeId(scopeId))).toEqual ['syntax--yellow syntax--broken'] diff --git a/spec/tokenized-buffer-spec.js b/spec/tokenized-buffer-spec.js new file mode 100644 index 000000000..ba43f9ff3 --- /dev/null +++ b/spec/tokenized-buffer-spec.js @@ -0,0 +1,1084 @@ +const NullGrammar = require('../src/null-grammar') +const TokenizedBuffer = require('../src/tokenized-buffer') +const TextBuffer = require('text-buffer') +const {Point, Range} = TextBuffer +const _ = require('underscore-plus') +const dedent = require('dedent') +const {it, fit, ffit, fffit, beforeEach, afterEach} = require('./async-spec-helpers') +const {ScopedSettingsDelegate} = require('../src/text-editor-registry') + +describe('TokenizedBuffer', () => { + let tokenizedBuffer, buffer + + beforeEach(async () => { + // enable async tokenization + TokenizedBuffer.prototype.chunkSize = 5 + jasmine.unspy(TokenizedBuffer.prototype, 'tokenizeInBackground') + await atom.packages.activatePackage('language-javascript') + }) + + afterEach(() => { + buffer && buffer.destroy() + tokenizedBuffer && tokenizedBuffer.destroy() + }) + + function startTokenizing (tokenizedBuffer) { + tokenizedBuffer.setVisible(true) + } + + function fullyTokenize (tokenizedBuffer) { + tokenizedBuffer.setVisible(true) + while (tokenizedBuffer.firstInvalidRow() != null) { + advanceClock() + } + } + + describe('serialization', () => { + describe('when the underlying buffer has a path', () => { + beforeEach(async () => { + buffer = atom.project.bufferForPathSync('sample.js') + await atom.packages.activatePackage('language-coffee-script') + }) + + it('deserializes it searching among the buffers in the current project', () => { + const tokenizedBufferA = new TokenizedBuffer({buffer, tabLength: 2}) + const tokenizedBufferB = TokenizedBuffer.deserialize(JSON.parse(JSON.stringify(tokenizedBufferA.serialize())), atom) + expect(tokenizedBufferB.buffer).toBe(tokenizedBufferA.buffer) + }) + }) + + describe('when the underlying buffer has no path', () => { + beforeEach(() => buffer = atom.project.bufferForPathSync(null)) + + it('deserializes it searching among the buffers in the current project', () => { + const tokenizedBufferA = new TokenizedBuffer({buffer, tabLength: 2}) + const tokenizedBufferB = TokenizedBuffer.deserialize(JSON.parse(JSON.stringify(tokenizedBufferA.serialize())), atom) + expect(tokenizedBufferB.buffer).toBe(tokenizedBufferA.buffer) + }) + }) + }) + + describe('tokenizing', () => { + describe('when the buffer is destroyed', () => { + beforeEach(() => { + buffer = atom.project.bufferForPathSync('sample.js') + tokenizedBuffer = new TokenizedBuffer({buffer, grammar: atom.grammars.grammarForScopeName('source.js'), tabLength: 2}) + startTokenizing(tokenizedBuffer) + }) + + it('stops tokenization', () => { + tokenizedBuffer.destroy() + spyOn(tokenizedBuffer, 'tokenizeNextChunk') + advanceClock() + expect(tokenizedBuffer.tokenizeNextChunk).not.toHaveBeenCalled() + }) + }) + + describe('when the buffer contains soft-tabs', () => { + beforeEach(() => { + buffer = atom.project.bufferForPathSync('sample.js') + tokenizedBuffer = new TokenizedBuffer({buffer, grammar: atom.grammars.grammarForScopeName('source.js'), tabLength: 2}) + startTokenizing(tokenizedBuffer) + }) + + afterEach(() => { + tokenizedBuffer.destroy() + buffer.release() + }) + + describe('on construction', () => + it('tokenizes lines chunk at a time in the background', () => { + const line0 = tokenizedBuffer.tokenizedLines[0] + expect(line0).toBeUndefined() + + const line11 = tokenizedBuffer.tokenizedLines[11] + expect(line11).toBeUndefined() + + // tokenize chunk 1 + advanceClock() + expect(tokenizedBuffer.tokenizedLines[0].ruleStack != null).toBeTruthy() + expect(tokenizedBuffer.tokenizedLines[4].ruleStack != null).toBeTruthy() + expect(tokenizedBuffer.tokenizedLines[5]).toBeUndefined() + + // tokenize chunk 2 + advanceClock() + expect(tokenizedBuffer.tokenizedLines[5].ruleStack != null).toBeTruthy() + expect(tokenizedBuffer.tokenizedLines[9].ruleStack != null).toBeTruthy() + expect(tokenizedBuffer.tokenizedLines[10]).toBeUndefined() + + // tokenize last chunk + advanceClock() + expect(tokenizedBuffer.tokenizedLines[10].ruleStack != null).toBeTruthy() + expect(tokenizedBuffer.tokenizedLines[12].ruleStack != null).toBeTruthy() + }) + ) + + describe('when the buffer is partially tokenized', () => { + beforeEach(() => { + // tokenize chunk 1 only + advanceClock() + }) + + describe('when there is a buffer change inside the tokenized region', () => { + describe('when lines are added', () => { + it('pushes the invalid rows down', () => { + expect(tokenizedBuffer.firstInvalidRow()).toBe(5) + buffer.insert([1, 0], '\n\n') + expect(tokenizedBuffer.firstInvalidRow()).toBe(7) + }) + }) + + describe('when lines are removed', () => { + it('pulls the invalid rows up', () => { + expect(tokenizedBuffer.firstInvalidRow()).toBe(5) + buffer.delete([[1, 0], [3, 0]]) + expect(tokenizedBuffer.firstInvalidRow()).toBe(2) + }) + }) + + describe('when the change invalidates all the lines before the current invalid region', () => { + it('retokenizes the invalidated lines and continues into the valid region', () => { + expect(tokenizedBuffer.firstInvalidRow()).toBe(5) + buffer.insert([2, 0], '/*') + expect(tokenizedBuffer.firstInvalidRow()).toBe(3) + advanceClock() + expect(tokenizedBuffer.firstInvalidRow()).toBe(8) + }) + }) + }) + + describe('when there is a buffer change surrounding an invalid row', () => { + it('pushes the invalid row to the end of the change', () => { + buffer.setTextInRange([[4, 0], [6, 0]], '\n\n\n') + expect(tokenizedBuffer.firstInvalidRow()).toBe(8) + }) + }) + + describe('when there is a buffer change inside an invalid region', () => { + it('does not attempt to tokenize the lines in the change, and preserves the existing invalid row', () => { + expect(tokenizedBuffer.firstInvalidRow()).toBe(5) + buffer.setTextInRange([[6, 0], [7, 0]], '\n\n\n') + expect(tokenizedBuffer.tokenizedLines[6]).toBeUndefined() + expect(tokenizedBuffer.tokenizedLines[7]).toBeUndefined() + expect(tokenizedBuffer.firstInvalidRow()).toBe(5) + }) + }) + }) + + describe('when the buffer is fully tokenized', () => { + beforeEach(() => fullyTokenize(tokenizedBuffer)) + + describe('when there is a buffer change that is smaller than the chunk size', () => { + describe('when lines are updated, but none are added or removed', () => { + it('updates tokens to reflect the change', () => { + buffer.setTextInRange([[0, 0], [2, 0]], 'foo()\n7\n') + + expect(tokenizedBuffer.tokenizedLines[0].tokens[1]).toEqual({value: '(', scopes: ['source.js', 'meta.function-call.js', 'meta.arguments.js', 'punctuation.definition.arguments.begin.bracket.round.js']}) + expect(tokenizedBuffer.tokenizedLines[1].tokens[0]).toEqual({value: '7', scopes: ['source.js', 'constant.numeric.decimal.js']}) + // line 2 is unchanged + expect(tokenizedBuffer.tokenizedLines[2].tokens[1]).toEqual({value: 'if', scopes: ['source.js', 'keyword.control.js']}) + }) + + describe('when the change invalidates the tokenization of subsequent lines', () => { + it('schedules the invalidated lines to be tokenized in the background', () => { + buffer.insert([5, 30], '/* */') + buffer.insert([2, 0], '/*') + expect(tokenizedBuffer.tokenizedLines[3].tokens[0].scopes).toEqual(['source.js']) + + advanceClock() + expect(tokenizedBuffer.tokenizedLines[3].tokens[0].scopes).toEqual(['source.js', 'comment.block.js']) + expect(tokenizedBuffer.tokenizedLines[4].tokens[0].scopes).toEqual(['source.js', 'comment.block.js']) + expect(tokenizedBuffer.tokenizedLines[5].tokens[0].scopes).toEqual(['source.js', 'comment.block.js']) + }) + }) + + it('resumes highlighting with the state of the previous line', () => { + buffer.insert([0, 0], '/*') + buffer.insert([5, 0], '*/') + + buffer.insert([1, 0], 'var ') + expect(tokenizedBuffer.tokenizedLines[1].tokens[0].scopes).toEqual(['source.js', 'comment.block.js']) + }) + }) + + describe('when lines are both updated and removed', () => { + it('updates tokens to reflect the change', () => { + buffer.setTextInRange([[1, 0], [3, 0]], 'foo()') + + // previous line 0 remains + expect(tokenizedBuffer.tokenizedLines[0].tokens[0]).toEqual({value: 'var', scopes: ['source.js', 'storage.type.var.js']}) + + // previous line 3 should be combined with input to form line 1 + expect(tokenizedBuffer.tokenizedLines[1].tokens[0]).toEqual({value: 'foo', scopes: ['source.js', 'meta.function-call.js', 'entity.name.function.js']}) + expect(tokenizedBuffer.tokenizedLines[1].tokens[6]).toEqual({value: '=', scopes: ['source.js', 'keyword.operator.assignment.js']}) + + // lines below deleted regions should be shifted upward + expect(tokenizedBuffer.tokenizedLines[2].tokens[1]).toEqual({value: 'while', scopes: ['source.js', 'keyword.control.js']}) + expect(tokenizedBuffer.tokenizedLines[3].tokens[1]).toEqual({value: '=', scopes: ['source.js', 'keyword.operator.assignment.js']}) + expect(tokenizedBuffer.tokenizedLines[4].tokens[1]).toEqual({value: '<', scopes: ['source.js', 'keyword.operator.comparison.js']}) + }) + }) + + describe('when the change invalidates the tokenization of subsequent lines', () => { + it('schedules the invalidated lines to be tokenized in the background', () => { + buffer.insert([5, 30], '/* */') + buffer.setTextInRange([[2, 0], [3, 0]], '/*') + expect(tokenizedBuffer.tokenizedLines[2].tokens[0].scopes).toEqual(['source.js', 'comment.block.js', 'punctuation.definition.comment.begin.js']) + expect(tokenizedBuffer.tokenizedLines[3].tokens[0].scopes).toEqual(['source.js']) + + advanceClock() + expect(tokenizedBuffer.tokenizedLines[3].tokens[0].scopes).toEqual(['source.js', 'comment.block.js']) + expect(tokenizedBuffer.tokenizedLines[4].tokens[0].scopes).toEqual(['source.js', 'comment.block.js']) + }) + }) + + describe('when lines are both updated and inserted', () => { + it('updates tokens to reflect the change', () => { + buffer.setTextInRange([[1, 0], [2, 0]], 'foo()\nbar()\nbaz()\nquux()') + + // previous line 0 remains + expect(tokenizedBuffer.tokenizedLines[0].tokens[0]).toEqual({ value: 'var', scopes: ['source.js', 'storage.type.var.js']}) + + // 3 new lines inserted + expect(tokenizedBuffer.tokenizedLines[1].tokens[0]).toEqual({value: 'foo', scopes: ['source.js', 'meta.function-call.js', 'entity.name.function.js']}) + expect(tokenizedBuffer.tokenizedLines[2].tokens[0]).toEqual({value: 'bar', scopes: ['source.js', 'meta.function-call.js', 'entity.name.function.js']}) + expect(tokenizedBuffer.tokenizedLines[3].tokens[0]).toEqual({value: 'baz', scopes: ['source.js', 'meta.function-call.js', 'entity.name.function.js']}) + + // previous line 2 is joined with quux() on line 4 + expect(tokenizedBuffer.tokenizedLines[4].tokens[0]).toEqual({value: 'quux', scopes: ['source.js', 'meta.function-call.js', 'entity.name.function.js']}) + expect(tokenizedBuffer.tokenizedLines[4].tokens[4]).toEqual({value: 'if', scopes: ['source.js', 'keyword.control.js']}) + + // previous line 3 is pushed down to become line 5 + expect(tokenizedBuffer.tokenizedLines[5].tokens[3]).toEqual({value: '=', scopes: ['source.js', 'keyword.operator.assignment.js']}) + }) + }) + + describe('when the change invalidates the tokenization of subsequent lines', () => { + it('schedules the invalidated lines to be tokenized in the background', () => { + buffer.insert([5, 30], '/* */') + buffer.insert([2, 0], '/*\nabcde\nabcder') + expect(tokenizedBuffer.tokenizedLines[2].tokens[0].scopes).toEqual(['source.js', 'comment.block.js', 'punctuation.definition.comment.begin.js']) + expect(tokenizedBuffer.tokenizedLines[3].tokens[0].scopes).toEqual(['source.js', 'comment.block.js']) + expect(tokenizedBuffer.tokenizedLines[4].tokens[0].scopes).toEqual(['source.js', 'comment.block.js']) + expect(tokenizedBuffer.tokenizedLines[5].tokens[0].scopes).toEqual(['source.js']) + + advanceClock() // tokenize invalidated lines in background + expect(tokenizedBuffer.tokenizedLines[5].tokens[0].scopes).toEqual(['source.js', 'comment.block.js']) + expect(tokenizedBuffer.tokenizedLines[6].tokens[0].scopes).toEqual(['source.js', 'comment.block.js']) + expect(tokenizedBuffer.tokenizedLines[7].tokens[0].scopes).toEqual(['source.js', 'comment.block.js']) + expect(tokenizedBuffer.tokenizedLines[8].tokens[0].scopes).not.toBe(['source.js', 'comment.block.js']) + }) + }) + }) + + describe('when there is an insertion that is larger than the chunk size', () => + it('tokenizes the initial chunk synchronously, then tokenizes the remaining lines in the background', () => { + const commentBlock = _.multiplyString('// a comment\n', tokenizedBuffer.chunkSize + 2) + buffer.insert([0, 0], commentBlock) + expect(tokenizedBuffer.tokenizedLines[0].ruleStack != null).toBeTruthy() + expect(tokenizedBuffer.tokenizedLines[4].ruleStack != null).toBeTruthy() + expect(tokenizedBuffer.tokenizedLines[5]).toBeUndefined() + + advanceClock() + expect(tokenizedBuffer.tokenizedLines[5].ruleStack != null).toBeTruthy() + expect(tokenizedBuffer.tokenizedLines[6].ruleStack != null).toBeTruthy() + }) + ) + + it('does not break out soft tabs across a scope boundary', async () => { + await atom.packages.activatePackage('language-gfm') + + tokenizedBuffer.setTabLength(4) + tokenizedBuffer.setGrammar(atom.grammars.selectGrammar('.md')) + buffer.setText(' 0) length += tag + } + + expect(length).toBe(4) + }) + }) + }) + + describe('when the buffer contains hard-tabs', () => { + beforeEach(async () => { + atom.packages.activatePackage('language-coffee-script') + + buffer = atom.project.bufferForPathSync('sample-with-tabs.coffee') + tokenizedBuffer = new TokenizedBuffer({buffer, grammar: atom.grammars.grammarForScopeName('source.coffee'), tabLength: 2}) + startTokenizing(tokenizedBuffer) + }) + + afterEach(() => { + tokenizedBuffer.destroy() + buffer.release() + }) + + describe('when the buffer is fully tokenized', () => { + beforeEach(() => fullyTokenize(tokenizedBuffer)) + }) + }) + + describe('when tokenization completes', () => { + it('emits the `tokenized` event', async () => { + const editor = await atom.workspace.open('sample.js') + + const tokenizedHandler = jasmine.createSpy('tokenized handler') + editor.tokenizedBuffer.onDidTokenize(tokenizedHandler) + fullyTokenize(editor.tokenizedBuffer) + expect(tokenizedHandler.callCount).toBe(1) + }) + + it("doesn't re-emit the `tokenized` event when it is re-tokenized", async () => { + const editor = await atom.workspace.open('sample.js') + fullyTokenize(editor.tokenizedBuffer) + + const tokenizedHandler = jasmine.createSpy('tokenized handler') + editor.tokenizedBuffer.onDidTokenize(tokenizedHandler) + editor.getBuffer().insert([0, 0], "'") + fullyTokenize(editor.tokenizedBuffer) + expect(tokenizedHandler).not.toHaveBeenCalled() + }) + }) + + describe('when the grammar is updated because a grammar it includes is activated', async () => { + it('re-emits the `tokenized` event', async () => { + const editor = await atom.workspace.open('coffee.coffee') + + const tokenizedHandler = jasmine.createSpy('tokenized handler') + editor.tokenizedBuffer.onDidTokenize(tokenizedHandler) + fullyTokenize(editor.tokenizedBuffer) + tokenizedHandler.reset() + + await atom.packages.activatePackage('language-coffee-script') + fullyTokenize(editor.tokenizedBuffer) + expect(tokenizedHandler.callCount).toBe(1) + }) + + it('retokenizes the buffer', async () => { + await atom.packages.activatePackage('language-ruby-on-rails') + await atom.packages.activatePackage('language-ruby') + + buffer = atom.project.bufferForPathSync() + buffer.setText("
<%= User.find(2).full_name %>
") + + tokenizedBuffer = new TokenizedBuffer({buffer, grammar: atom.grammars.selectGrammar('test.erb'), tabLength: 2}) + fullyTokenize(tokenizedBuffer) + expect(tokenizedBuffer.tokenizedLines[0].tokens[0]).toEqual({ + value: "
", + scopes: ['text.html.ruby'] + }) + + await atom.packages.activatePackage('language-html') + fullyTokenize(tokenizedBuffer) + expect(tokenizedBuffer.tokenizedLines[0].tokens[0]).toEqual({ + value: '<', + scopes: ['text.html.ruby', 'meta.tag.block.div.html', 'punctuation.definition.tag.begin.html'] + }) + }) + }) + + describe('when the buffer is configured with the null grammar', () => { + it('does not actually tokenize using the grammar', () => { + spyOn(NullGrammar, 'tokenizeLine').andCallThrough() + buffer = atom.project.bufferForPathSync('sample.will-use-the-null-grammar') + buffer.setText('a\nb\nc') + tokenizedBuffer = new TokenizedBuffer({buffer, tabLength: 2}) + const tokenizeCallback = jasmine.createSpy('onDidTokenize') + tokenizedBuffer.onDidTokenize(tokenizeCallback) + + expect(tokenizedBuffer.tokenizedLines[0]).toBeUndefined() + expect(tokenizedBuffer.tokenizedLines[1]).toBeUndefined() + expect(tokenizedBuffer.tokenizedLines[2]).toBeUndefined() + expect(tokenizeCallback.callCount).toBe(0) + expect(NullGrammar.tokenizeLine).not.toHaveBeenCalled() + + fullyTokenize(tokenizedBuffer) + expect(tokenizedBuffer.tokenizedLines[0]).toBeUndefined() + expect(tokenizedBuffer.tokenizedLines[1]).toBeUndefined() + expect(tokenizedBuffer.tokenizedLines[2]).toBeUndefined() + expect(tokenizeCallback.callCount).toBe(0) + expect(NullGrammar.tokenizeLine).not.toHaveBeenCalled() + }) + }) + }) + + describe('.tokenForPosition(position)', () => { + afterEach(() => { + tokenizedBuffer.destroy() + buffer.release() + }) + + it('returns the correct token (regression)', () => { + buffer = atom.project.bufferForPathSync('sample.js') + tokenizedBuffer = new TokenizedBuffer({buffer, grammar: atom.grammars.grammarForScopeName('source.js'), tabLength: 2}) + fullyTokenize(tokenizedBuffer) + expect(tokenizedBuffer.tokenForPosition([1, 0]).scopes).toEqual(['source.js']) + expect(tokenizedBuffer.tokenForPosition([1, 1]).scopes).toEqual(['source.js']) + expect(tokenizedBuffer.tokenForPosition([1, 2]).scopes).toEqual(['source.js', 'storage.type.var.js']) + }) + }) + + describe('.bufferRangeForScopeAtPosition(selector, position)', () => { + beforeEach(() => { + buffer = atom.project.bufferForPathSync('sample.js') + tokenizedBuffer = new TokenizedBuffer({buffer, grammar: atom.grammars.grammarForScopeName('source.js'), tabLength: 2}) + fullyTokenize(tokenizedBuffer) + }) + + describe('when the selector does not match the token at the position', () => + it('returns a falsy value', () => expect(tokenizedBuffer.bufferRangeForScopeAtPosition('.bogus', [0, 1])).toBeUndefined()) + ) + + describe('when the selector matches a single token at the position', () => { + it('returns the range covered by the token', () => { + expect(tokenizedBuffer.bufferRangeForScopeAtPosition('.storage.type.var.js', [0, 1])).toEqual([[0, 0], [0, 3]]) + expect(tokenizedBuffer.bufferRangeForScopeAtPosition('.storage.type.var.js', [0, 3])).toEqual([[0, 0], [0, 3]]) + }) + }) + + describe('when the selector matches a run of multiple tokens at the position', () => { + it('returns the range covered by all contiguous tokens (within a single line)', () => { + expect(tokenizedBuffer.bufferRangeForScopeAtPosition('.function', [1, 18])).toEqual([[1, 6], [1, 28]]) + }) + }) + }) + + describe('.tokenizedLineForRow(row)', () => { + it("returns the tokenized line for a row, or a placeholder line if it hasn't been tokenized yet", () => { + buffer = atom.project.bufferForPathSync('sample.js') + const grammar = atom.grammars.grammarForScopeName('source.js') + tokenizedBuffer = new TokenizedBuffer({buffer, grammar, tabLength: 2}) + const line0 = buffer.lineForRow(0) + + const jsScopeStartId = grammar.startIdForScope(grammar.scopeName) + const jsScopeEndId = grammar.endIdForScope(grammar.scopeName) + startTokenizing(tokenizedBuffer) + expect(tokenizedBuffer.tokenizedLines[0]).toBeUndefined() + expect(tokenizedBuffer.tokenizedLineForRow(0).text).toBe(line0) + expect(tokenizedBuffer.tokenizedLineForRow(0).tags).toEqual([jsScopeStartId, line0.length, jsScopeEndId]) + advanceClock(1) + expect(tokenizedBuffer.tokenizedLines[0]).not.toBeUndefined() + expect(tokenizedBuffer.tokenizedLineForRow(0).text).toBe(line0) + expect(tokenizedBuffer.tokenizedLineForRow(0).tags).not.toEqual([jsScopeStartId, line0.length, jsScopeEndId]) + + const nullScopeStartId = NullGrammar.startIdForScope(NullGrammar.scopeName) + const nullScopeEndId = NullGrammar.endIdForScope(NullGrammar.scopeName) + tokenizedBuffer.setGrammar(NullGrammar) + startTokenizing(tokenizedBuffer) + expect(tokenizedBuffer.tokenizedLines[0]).toBeUndefined() + expect(tokenizedBuffer.tokenizedLineForRow(0).text).toBe(line0) + expect(tokenizedBuffer.tokenizedLineForRow(0).tags).toEqual([nullScopeStartId, line0.length, nullScopeEndId]) + advanceClock(1) + expect(tokenizedBuffer.tokenizedLineForRow(0).text).toBe(line0) + expect(tokenizedBuffer.tokenizedLineForRow(0).tags).toEqual([nullScopeStartId, line0.length, nullScopeEndId]) + }) + + it('returns undefined if the requested row is outside the buffer range', () => { + buffer = atom.project.bufferForPathSync('sample.js') + const grammar = atom.grammars.grammarForScopeName('source.js') + tokenizedBuffer = new TokenizedBuffer({buffer, grammar, tabLength: 2}) + fullyTokenize(tokenizedBuffer) + expect(tokenizedBuffer.tokenizedLineForRow(999)).toBeUndefined() + }) + }) + + describe('text decoration layer API', () => { + describe('iterator', () => { + it('iterates over the syntactic scope boundaries', () => { + buffer = new TextBuffer({text: 'var foo = 1 /*\nhello*/var bar = 2\n'}) + tokenizedBuffer = new TokenizedBuffer({buffer, grammar: atom.grammars.grammarForScopeName('source.js'), tabLength: 2}) + fullyTokenize(tokenizedBuffer) + + const iterator = tokenizedBuffer.buildIterator() + iterator.seek(Point(0, 0)) + + const expectedBoundaries = [ + {position: Point(0, 0), closeTags: [], openTags: ['syntax--source syntax--js', 'syntax--storage syntax--type syntax--var syntax--js']}, + {position: Point(0, 3), closeTags: ['syntax--storage syntax--type syntax--var syntax--js'], openTags: []}, + {position: Point(0, 8), closeTags: [], openTags: ['syntax--keyword syntax--operator syntax--assignment syntax--js']}, + {position: Point(0, 9), closeTags: ['syntax--keyword syntax--operator syntax--assignment syntax--js'], openTags: []}, + {position: Point(0, 10), closeTags: [], openTags: ['syntax--constant syntax--numeric syntax--decimal syntax--js']}, + {position: Point(0, 11), closeTags: ['syntax--constant syntax--numeric syntax--decimal syntax--js'], openTags: []}, + {position: Point(0, 12), closeTags: [], openTags: ['syntax--comment syntax--block syntax--js', 'syntax--punctuation syntax--definition syntax--comment syntax--begin syntax--js']}, + {position: Point(0, 14), closeTags: ['syntax--punctuation syntax--definition syntax--comment syntax--begin syntax--js'], openTags: []}, + {position: Point(1, 5), closeTags: [], openTags: ['syntax--punctuation syntax--definition syntax--comment syntax--end syntax--js']}, + {position: Point(1, 7), closeTags: ['syntax--punctuation syntax--definition syntax--comment syntax--end syntax--js', 'syntax--comment syntax--block syntax--js'], openTags: ['syntax--storage syntax--type syntax--var syntax--js']}, + {position: Point(1, 10), closeTags: ['syntax--storage syntax--type syntax--var syntax--js'], openTags: []}, + {position: Point(1, 15), closeTags: [], openTags: ['syntax--keyword syntax--operator syntax--assignment syntax--js']}, + {position: Point(1, 16), closeTags: ['syntax--keyword syntax--operator syntax--assignment syntax--js'], openTags: []}, + {position: Point(1, 17), closeTags: [], openTags: ['syntax--constant syntax--numeric syntax--decimal syntax--js']}, + {position: Point(1, 18), closeTags: ['syntax--constant syntax--numeric syntax--decimal syntax--js'], openTags: []} + ] + + while (true) { + const boundary = { + position: iterator.getPosition(), + closeTags: iterator.getCloseScopeIds().map(scopeId => tokenizedBuffer.classNameForScopeId(scopeId)), + openTags: iterator.getOpenScopeIds().map(scopeId => tokenizedBuffer.classNameForScopeId(scopeId)) + } + + expect(boundary).toEqual(expectedBoundaries.shift()) + if (!iterator.moveToSuccessor()) { break } + } + + expect(iterator.seek(Point(0, 1)).map(scopeId => tokenizedBuffer.classNameForScopeId(scopeId))).toEqual([ + 'syntax--source syntax--js', + 'syntax--storage syntax--type syntax--var syntax--js' + ]) + expect(iterator.getPosition()).toEqual(Point(0, 3)) + expect(iterator.seek(Point(0, 8)).map(scopeId => tokenizedBuffer.classNameForScopeId(scopeId))).toEqual([ + 'syntax--source syntax--js' + ]) + expect(iterator.getPosition()).toEqual(Point(0, 8)) + expect(iterator.seek(Point(1, 0)).map(scopeId => tokenizedBuffer.classNameForScopeId(scopeId))).toEqual([ + 'syntax--source syntax--js', + 'syntax--comment syntax--block syntax--js' + ]) + expect(iterator.getPosition()).toEqual(Point(1, 0)) + expect(iterator.seek(Point(1, 18)).map(scopeId => tokenizedBuffer.classNameForScopeId(scopeId))).toEqual([ + 'syntax--source syntax--js', + 'syntax--constant syntax--numeric syntax--decimal syntax--js' + ]) + expect(iterator.getPosition()).toEqual(Point(1, 18)) + + expect(iterator.seek(Point(2, 0)).map(scopeId => tokenizedBuffer.classNameForScopeId(scopeId))).toEqual([ + 'syntax--source syntax--js' + ]) + iterator.moveToSuccessor() + }) // ensure we don't infinitely loop (regression test) + + it('does not report columns beyond the length of the line', async () => { + await atom.packages.activatePackage('language-coffee-script') + + buffer = new TextBuffer({text: '# hello\n# world'}) + tokenizedBuffer = new TokenizedBuffer({buffer, grammar: atom.grammars.grammarForScopeName('source.coffee'), tabLength: 2}) + fullyTokenize(tokenizedBuffer) + + const iterator = tokenizedBuffer.buildIterator() + iterator.seek(Point(0, 0)) + iterator.moveToSuccessor() + iterator.moveToSuccessor() + expect(iterator.getPosition().column).toBe(7) + + iterator.moveToSuccessor() + expect(iterator.getPosition().column).toBe(0) + + iterator.seek(Point(0, 7)) + expect(iterator.getPosition().column).toBe(7) + + iterator.seek(Point(0, 8)) + expect(iterator.getPosition().column).toBe(7) + }) + + it('correctly terminates scopes at the beginning of the line (regression)', () => { + const grammar = atom.grammars.createGrammar('test', { + 'scopeName': 'text.broken', + 'name': 'Broken grammar', + 'patterns': [ + {'begin': 'start', 'end': '(?=end)', 'name': 'blue.broken'}, + {'match': '.', 'name': 'yellow.broken'} + ] + }) + + buffer = new TextBuffer({text: 'start x\nend x\nx'}) + tokenizedBuffer = new TokenizedBuffer({buffer, grammar, tabLength: 2}) + fullyTokenize(tokenizedBuffer) + + const iterator = tokenizedBuffer.buildIterator() + iterator.seek(Point(1, 0)) + + expect(iterator.getPosition()).toEqual([1, 0]) + expect(iterator.getCloseScopeIds().map(scopeId => tokenizedBuffer.classNameForScopeId(scopeId))).toEqual(['syntax--blue syntax--broken']) + expect(iterator.getOpenScopeIds().map(scopeId => tokenizedBuffer.classNameForScopeId(scopeId))).toEqual(['syntax--yellow syntax--broken']) + }) + }) + }) + + describe('.suggestedIndentForBufferRow', () => { + let editor + + describe('javascript', () => { + beforeEach(async () => { + editor = await atom.workspace.open('sample.js', {autoIndent: false}) + await atom.packages.activatePackage('language-javascript') + }) + + it('bases indentation off of the previous non-blank line', () => { + expect(editor.suggestedIndentForBufferRow(0)).toBe(0) + expect(editor.suggestedIndentForBufferRow(1)).toBe(1) + expect(editor.suggestedIndentForBufferRow(2)).toBe(2) + expect(editor.suggestedIndentForBufferRow(5)).toBe(3) + expect(editor.suggestedIndentForBufferRow(7)).toBe(2) + expect(editor.suggestedIndentForBufferRow(9)).toBe(1) + expect(editor.suggestedIndentForBufferRow(11)).toBe(1) + }) + + it('does not take invisibles into account', () => { + editor.update({showInvisibles: true}) + expect(editor.suggestedIndentForBufferRow(0)).toBe(0) + expect(editor.suggestedIndentForBufferRow(1)).toBe(1) + expect(editor.suggestedIndentForBufferRow(2)).toBe(2) + expect(editor.suggestedIndentForBufferRow(5)).toBe(3) + expect(editor.suggestedIndentForBufferRow(7)).toBe(2) + expect(editor.suggestedIndentForBufferRow(9)).toBe(1) + expect(editor.suggestedIndentForBufferRow(11)).toBe(1) + }) + }) + + describe('css', () => { + beforeEach(async () => { + editor = await atom.workspace.open('css.css', {autoIndent: true}) + await atom.packages.activatePackage('language-source') + await atom.packages.activatePackage('language-css') + }) + + it('does not return negative values (regression)', () => { + editor.setText('.test {\npadding: 0;\n}') + expect(editor.suggestedIndentForBufferRow(2)).toBe(0) + }) + }) + }) + + describe('.toggleLineCommentsForBufferRows', () => { + describe('xml', () => { + beforeEach(async () => { + await atom.packages.activatePackage('language-xml') + buffer = new TextBuffer('') + tokenizedBuffer = new TokenizedBuffer({ + buffer, + grammar: atom.grammars.grammarForScopeName('text.xml'), + scopedSettingsDelegate: new ScopedSettingsDelegate(atom.config) + }) + }) + + it('removes the leading whitespace from the comment end pattern match when uncommenting lines', () => { + tokenizedBuffer.toggleLineCommentsForBufferRows(0, 0) + expect(buffer.lineForRow(0)).toBe('test') + }) + }) + + describe('less', () => { + beforeEach(async () => { + await atom.packages.activatePackage('language-less') + await atom.packages.activatePackage('language-css') + buffer = await TextBuffer.load(require.resolve('./fixtures/sample.less')) + tokenizedBuffer = new TokenizedBuffer({ + buffer, + grammar: atom.grammars.grammarForScopeName('source.css.less'), + scopedSettingsDelegate: new ScopedSettingsDelegate(atom.config) + }) + }) + + it('only uses the `commentEnd` pattern if it comes from the same grammar as the `commentStart` when commenting lines', () => { + tokenizedBuffer.toggleLineCommentsForBufferRows(0, 0) + expect(buffer.lineForRow(0)).toBe('// @color: #4D926F;') + }) + }) + + describe('css', () => { + beforeEach(async () => { + await atom.packages.activatePackage('language-css') + buffer = await TextBuffer.load(require.resolve('./fixtures/css.css')) + tokenizedBuffer = new TokenizedBuffer({ + buffer, + grammar: atom.grammars.grammarForScopeName('source.css'), + scopedSettingsDelegate: new ScopedSettingsDelegate(atom.config) + }) + }) + + it('comments/uncomments lines in the given range', () => { + tokenizedBuffer.toggleLineCommentsForBufferRows(0, 1) + expect(buffer.lineForRow(0)).toBe('/*body {') + expect(buffer.lineForRow(1)).toBe(' font-size: 1234px;*/') + expect(buffer.lineForRow(2)).toBe(' width: 110%;') + expect(buffer.lineForRow(3)).toBe(' font-weight: bold !important;') + + tokenizedBuffer.toggleLineCommentsForBufferRows(2, 2) + expect(buffer.lineForRow(0)).toBe('/*body {') + expect(buffer.lineForRow(1)).toBe(' font-size: 1234px;*/') + expect(buffer.lineForRow(2)).toBe(' /*width: 110%;*/') + expect(buffer.lineForRow(3)).toBe(' font-weight: bold !important;') + + tokenizedBuffer.toggleLineCommentsForBufferRows(0, 1) + expect(buffer.lineForRow(0)).toBe('body {') + expect(buffer.lineForRow(1)).toBe(' font-size: 1234px;') + expect(buffer.lineForRow(2)).toBe(' /*width: 110%;*/') + expect(buffer.lineForRow(3)).toBe(' font-weight: bold !important;') + }) + + it('uncomments lines with leading whitespace', () => { + buffer.setTextInRange([[2, 0], [2, Infinity]], ' /*width: 110%;*/') + tokenizedBuffer.toggleLineCommentsForBufferRows(2, 2) + expect(buffer.lineForRow(2)).toBe(' width: 110%;') + }) + + it('uncomments lines with trailing whitespace', () => { + buffer.setTextInRange([[2, 0], [2, Infinity]], '/*width: 110%;*/ ') + tokenizedBuffer.toggleLineCommentsForBufferRows(2, 2) + expect(buffer.lineForRow(2)).toBe('width: 110%; ') + }) + + it('uncomments lines with leading and trailing whitespace', () => { + buffer.setTextInRange([[2, 0], [2, Infinity]], ' /*width: 110%;*/ ') + tokenizedBuffer.toggleLineCommentsForBufferRows(2, 2) + expect(buffer.lineForRow(2)).toBe(' width: 110%; ') + }) + }) + + describe('coffeescript', () => { + beforeEach(async () => { + await atom.packages.activatePackage('language-coffee-script') + buffer = await TextBuffer.load(require.resolve('./fixtures/coffee.coffee')) + tokenizedBuffer = new TokenizedBuffer({ + buffer, + tabLength: 2, + grammar: atom.grammars.grammarForScopeName('source.coffee'), + scopedSettingsDelegate: new ScopedSettingsDelegate(atom.config) + }) + }) + + it('comments/uncomments lines in the given range', () => { + tokenizedBuffer.toggleLineCommentsForBufferRows(4, 6) + expect(buffer.lineForRow(4)).toBe(' # pivot = items.shift()') + expect(buffer.lineForRow(5)).toBe(' # left = []') + expect(buffer.lineForRow(6)).toBe(' # right = []') + + tokenizedBuffer.toggleLineCommentsForBufferRows(4, 5) + expect(buffer.lineForRow(4)).toBe(' pivot = items.shift()') + expect(buffer.lineForRow(5)).toBe(' left = []') + expect(buffer.lineForRow(6)).toBe(' # right = []') + }) + + it('comments/uncomments empty lines', () => { + tokenizedBuffer.toggleLineCommentsForBufferRows(4, 7) + expect(buffer.lineForRow(4)).toBe(' # pivot = items.shift()') + expect(buffer.lineForRow(5)).toBe(' # left = []') + expect(buffer.lineForRow(6)).toBe(' # right = []') + expect(buffer.lineForRow(7)).toBe(' # ') + + tokenizedBuffer.toggleLineCommentsForBufferRows(4, 5) + expect(buffer.lineForRow(4)).toBe(' pivot = items.shift()') + expect(buffer.lineForRow(5)).toBe(' left = []') + expect(buffer.lineForRow(6)).toBe(' # right = []') + expect(buffer.lineForRow(7)).toBe(' # ') + }) + }) + + describe('javascript', () => { + beforeEach(async () => { + await atom.packages.activatePackage('language-javascript') + buffer = await TextBuffer.load(require.resolve('./fixtures/sample.js')) + tokenizedBuffer = new TokenizedBuffer({ + buffer, + tabLength: 2, + grammar: atom.grammars.grammarForScopeName('source.js'), + scopedSettingsDelegate: new ScopedSettingsDelegate(atom.config) + }) + }) + + it('comments/uncomments lines in the given range', () => { + tokenizedBuffer.toggleLineCommentsForBufferRows(4, 7) + expect(buffer.lineForRow(4)).toBe(' // while(items.length > 0) {') + expect(buffer.lineForRow(5)).toBe(' // current = items.shift();') + expect(buffer.lineForRow(6)).toBe(' // current < pivot ? left.push(current) : right.push(current);') + expect(buffer.lineForRow(7)).toBe(' // }') + + tokenizedBuffer.toggleLineCommentsForBufferRows(4, 5) + expect(buffer.lineForRow(4)).toBe(' while(items.length > 0) {') + expect(buffer.lineForRow(5)).toBe(' current = items.shift();') + expect(buffer.lineForRow(6)).toBe(' // current < pivot ? left.push(current) : right.push(current);') + expect(buffer.lineForRow(7)).toBe(' // }') + + buffer.setText('\tvar i;') + tokenizedBuffer.toggleLineCommentsForBufferRows(0, 0) + expect(buffer.lineForRow(0)).toBe('\t// var i;') + + buffer.setText('var i;') + tokenizedBuffer.toggleLineCommentsForBufferRows(0, 0) + expect(buffer.lineForRow(0)).toBe('// var i;') + + buffer.setText(' var i;') + tokenizedBuffer.toggleLineCommentsForBufferRows(0, 0) + expect(buffer.lineForRow(0)).toBe(' // var i;') + + buffer.setText(' ') + tokenizedBuffer.toggleLineCommentsForBufferRows(0, 0) + expect(buffer.lineForRow(0)).toBe(' // ') + + buffer.setText(' a\n \n b') + tokenizedBuffer.toggleLineCommentsForBufferRows(0, 2) + expect(buffer.lineForRow(0)).toBe(' // a') + expect(buffer.lineForRow(1)).toBe(' // ') + expect(buffer.lineForRow(2)).toBe(' // b') + + buffer.setText(' \n // var i;') + tokenizedBuffer.toggleLineCommentsForBufferRows(0, 1) + expect(buffer.lineForRow(0)).toBe(' ') + expect(buffer.lineForRow(1)).toBe(' var i;') + }) + }) + }) + + describe('.isFoldableAtRow(row)', () => { + beforeEach(() => { + buffer = atom.project.bufferForPathSync('sample.js') + buffer.insert([10, 0], ' // multi-line\n // comment\n // block\n') + buffer.insert([0, 0], '// multi-line\n// comment\n// block\n') + tokenizedBuffer = new TokenizedBuffer({buffer, grammar: atom.grammars.grammarForScopeName('source.js'), tabLength: 2}) + fullyTokenize(tokenizedBuffer) + }) + + it('includes the first line of multi-line comments', () => { + expect(tokenizedBuffer.isFoldableAtRow(0)).toBe(true) + expect(tokenizedBuffer.isFoldableAtRow(1)).toBe(false) + expect(tokenizedBuffer.isFoldableAtRow(2)).toBe(false) + expect(tokenizedBuffer.isFoldableAtRow(3)).toBe(true) // because of indent + expect(tokenizedBuffer.isFoldableAtRow(13)).toBe(true) + expect(tokenizedBuffer.isFoldableAtRow(14)).toBe(false) + expect(tokenizedBuffer.isFoldableAtRow(15)).toBe(false) + expect(tokenizedBuffer.isFoldableAtRow(16)).toBe(false) + + buffer.insert([0, Infinity], '\n') + + expect(tokenizedBuffer.isFoldableAtRow(0)).toBe(false) + expect(tokenizedBuffer.isFoldableAtRow(1)).toBe(false) + expect(tokenizedBuffer.isFoldableAtRow(2)).toBe(true) + expect(tokenizedBuffer.isFoldableAtRow(3)).toBe(false) + + buffer.undo() + + expect(tokenizedBuffer.isFoldableAtRow(0)).toBe(true) + expect(tokenizedBuffer.isFoldableAtRow(1)).toBe(false) + expect(tokenizedBuffer.isFoldableAtRow(2)).toBe(false) + expect(tokenizedBuffer.isFoldableAtRow(3)).toBe(true) + }) // because of indent + + it('includes non-comment lines that precede an increase in indentation', () => { + buffer.insert([2, 0], ' ') // commented lines preceding an indent aren't foldable + + expect(tokenizedBuffer.isFoldableAtRow(1)).toBe(false) + expect(tokenizedBuffer.isFoldableAtRow(2)).toBe(false) + expect(tokenizedBuffer.isFoldableAtRow(3)).toBe(true) + expect(tokenizedBuffer.isFoldableAtRow(4)).toBe(true) + expect(tokenizedBuffer.isFoldableAtRow(5)).toBe(false) + expect(tokenizedBuffer.isFoldableAtRow(6)).toBe(false) + expect(tokenizedBuffer.isFoldableAtRow(7)).toBe(true) + expect(tokenizedBuffer.isFoldableAtRow(8)).toBe(false) + + buffer.insert([7, 0], ' ') + + expect(tokenizedBuffer.isFoldableAtRow(6)).toBe(true) + expect(tokenizedBuffer.isFoldableAtRow(7)).toBe(false) + expect(tokenizedBuffer.isFoldableAtRow(8)).toBe(false) + + buffer.undo() + + expect(tokenizedBuffer.isFoldableAtRow(6)).toBe(false) + expect(tokenizedBuffer.isFoldableAtRow(7)).toBe(true) + expect(tokenizedBuffer.isFoldableAtRow(8)).toBe(false) + + buffer.insert([7, 0], ' \n x\n') + + expect(tokenizedBuffer.isFoldableAtRow(6)).toBe(true) + expect(tokenizedBuffer.isFoldableAtRow(7)).toBe(false) + expect(tokenizedBuffer.isFoldableAtRow(8)).toBe(false) + + buffer.insert([9, 0], ' ') + + expect(tokenizedBuffer.isFoldableAtRow(6)).toBe(true) + expect(tokenizedBuffer.isFoldableAtRow(7)).toBe(false) + expect(tokenizedBuffer.isFoldableAtRow(8)).toBe(false) + }) + }) + + describe('.getFoldableRangesAtIndentLevel', () => { + it('returns the ranges that can be folded at the given indent level', () => { + buffer = new TextBuffer(dedent ` + if (a) { + b(); + if (c) { + d() + if (e) { + f() + } + g() + } + h() + } + i() + if (j) { + k() + } + `) + + tokenizedBuffer = new TokenizedBuffer({buffer}) + + expect(simulateFold(tokenizedBuffer.getFoldableRangesAtIndentLevel(0, 2))).toBe(dedent ` + if (a) {⋯ + } + i() + if (j) {⋯ + } + `) + + expect(simulateFold(tokenizedBuffer.getFoldableRangesAtIndentLevel(1, 2))).toBe(dedent ` + if (a) { + b(); + if (c) {⋯ + } + h() + } + i() + if (j) { + k() + } + `) + + expect(simulateFold(tokenizedBuffer.getFoldableRangesAtIndentLevel(2, 2))).toBe(dedent ` + if (a) { + b(); + if (c) { + d() + if (e) {⋯ + } + g() + } + h() + } + i() + if (j) { + k() + } + `) + }) + }) + + describe('.getFoldableRanges', () => { + it('returns the ranges that can be folded', () => { + buffer = new TextBuffer(dedent ` + if (a) { + b(); + if (c) { + d() + if (e) { + f() + } + g() + } + h() + } + i() + if (j) { + k() + } + `) + + tokenizedBuffer = new TokenizedBuffer({buffer}) + + expect(tokenizedBuffer.getFoldableRanges(2).map(r => r.toString())).toEqual([ + ...tokenizedBuffer.getFoldableRangesAtIndentLevel(0, 2), + ...tokenizedBuffer.getFoldableRangesAtIndentLevel(1, 2), + ...tokenizedBuffer.getFoldableRangesAtIndentLevel(2, 2), + ].sort((a, b) => (a.start.row - b.start.row) || (a.end.row - b.end.row)).map(r => r.toString())) + }) + }) + + describe('.getFoldableRangeContainingPoint', () => { + it('returns the range for the smallest fold that contains the given range', () => { + buffer = new TextBuffer(dedent ` + if (a) { + b(); + if (c) { + d() + if (e) { + f() + } + g() + } + h() + } + i() + if (j) { + k() + } + `) + + tokenizedBuffer = new TokenizedBuffer({buffer}) + + expect(tokenizedBuffer.getFoldableRangeContainingPoint(Point(0, 5), 2)).toBeNull() + + let range = tokenizedBuffer.getFoldableRangeContainingPoint(Point(0, 10), 2) + expect(simulateFold([range])).toBe(dedent ` + if (a) {⋯ + } + i() + if (j) { + k() + } + `) + + range = tokenizedBuffer.getFoldableRangeContainingPoint(Point(1, Infinity), 2) + expect(simulateFold([range])).toBe(dedent ` + if (a) {⋯ + } + i() + if (j) { + k() + } + `) + + range = tokenizedBuffer.getFoldableRangeContainingPoint(Point(2, 20), 2) + expect(simulateFold([range])).toBe(dedent ` + if (a) { + b(); + if (c) {⋯ + } + h() + } + i() + if (j) { + k() + } + `) + }) + + it('works for coffee-script', async () => { + const editor = await atom.workspace.open('coffee.coffee') + await atom.packages.activatePackage('language-coffee-script') + buffer = editor.buffer + tokenizedBuffer = editor.tokenizedBuffer + + expect(tokenizedBuffer.getFoldableRangeContainingPoint(Point(0, Infinity))).toEqual([[0, Infinity], [20, Infinity]]) + expect(tokenizedBuffer.getFoldableRangeContainingPoint(Point(1, Infinity))).toEqual([[1, Infinity], [17, Infinity]]) + expect(tokenizedBuffer.getFoldableRangeContainingPoint(Point(2, Infinity))).toEqual([[1, Infinity], [17, Infinity]]) + expect(tokenizedBuffer.getFoldableRangeContainingPoint(Point(19, Infinity))).toEqual([[19, Infinity], [20, Infinity]]) + }) + + it('works for javascript', async () => { + const editor = await atom.workspace.open('sample.js') + await atom.packages.activatePackage('language-javascript') + buffer = editor.buffer + tokenizedBuffer = editor.tokenizedBuffer + + expect(editor.tokenizedBuffer.getFoldableRangeContainingPoint(Point(0, Infinity))).toEqual([[0, Infinity], [12, Infinity]]) + expect(editor.tokenizedBuffer.getFoldableRangeContainingPoint(Point(1, Infinity))).toEqual([[1, Infinity], [9, Infinity]]) + expect(editor.tokenizedBuffer.getFoldableRangeContainingPoint(Point(2, Infinity))).toEqual([[1, Infinity], [9, Infinity]]) + expect(editor.tokenizedBuffer.getFoldableRangeContainingPoint(Point(4, Infinity))).toEqual([[4, Infinity], [7, Infinity]]) + }) + }) + + function simulateFold (ranges) { + buffer.transact(() => { + for (const range of ranges.reverse()) { + buffer.setTextInRange(range, '⋯') + } + }) + let text = buffer.getText() + buffer.undo() + return text + } +}) diff --git a/src/color.js b/src/color.js index 6208d6837..2f2947e16 100644 --- a/src/color.js +++ b/src/color.js @@ -112,27 +112,15 @@ export default class Color { function parseColor (colorString) { const color = parseInt(colorString, 10) - if (isNaN(color)) { - return 0 - } else { - return Math.min(Math.max(color, 0), 255) - } + return isNaN(color) ? 0 : Math.min(Math.max(color, 0), 255) } function parseAlpha (alphaString) { const alpha = parseFloat(alphaString) - if (isNaN(alpha)) { - return 1 - } else { - return Math.min(Math.max(alpha, 0), 1) - } + return isNaN(alpha) ? 1 : Math.min(Math.max(alpha, 0), 1) } function numberToHexString (number) { const hex = number.toString(16) - if (number < 16) { - return `0${hex}` - } else { - return hex - } + return number < 16 ? `0${hex}` : hex } diff --git a/src/cursor.coffee b/src/cursor.coffee deleted file mode 100644 index 6273b0276..000000000 --- a/src/cursor.coffee +++ /dev/null @@ -1,659 +0,0 @@ -{Point, Range} = require 'text-buffer' -{Emitter} = require 'event-kit' -_ = require 'underscore-plus' -Model = require './model' - -EmptyLineRegExp = /(\r\n[\t ]*\r\n)|(\n[\t ]*\n)/g - -# Extended: The `Cursor` class represents the little blinking line identifying -# where text can be inserted. -# -# Cursors belong to {TextEditor}s and have some metadata attached in the form -# of a {DisplayMarker}. -module.exports = -class Cursor extends Model - screenPosition: null - bufferPosition: null - goalColumn: null - - # Instantiated by a {TextEditor} - constructor: ({@editor, @marker, id}) -> - @emitter = new Emitter - @assignId(id) - - destroy: -> - @marker.destroy() - - ### - Section: Event Subscription - ### - - # Public: Calls your `callback` when the cursor has been moved. - # - # * `callback` {Function} - # * `event` {Object} - # * `oldBufferPosition` {Point} - # * `oldScreenPosition` {Point} - # * `newBufferPosition` {Point} - # * `newScreenPosition` {Point} - # * `textChanged` {Boolean} - # * `cursor` {Cursor} that triggered the event - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidChangePosition: (callback) -> - @emitter.on 'did-change-position', callback - - # Public: Calls your `callback` when the cursor is destroyed - # - # * `callback` {Function} - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidDestroy: (callback) -> - @emitter.once 'did-destroy', callback - - ### - Section: Managing Cursor Position - ### - - # Public: Moves a cursor to a given screen position. - # - # * `screenPosition` {Array} of two numbers: the screen row, and the screen column. - # * `options` (optional) {Object} with the following keys: - # * `autoscroll` A Boolean which, if `true`, scrolls the {TextEditor} to wherever - # the cursor moves to. - setScreenPosition: (screenPosition, options={}) -> - @changePosition options, => - @marker.setHeadScreenPosition(screenPosition, options) - - # Public: Returns the screen position of the cursor as a {Point}. - getScreenPosition: -> - @marker.getHeadScreenPosition() - - # Public: Moves a cursor to a given buffer position. - # - # * `bufferPosition` {Array} of two numbers: the buffer row, and the buffer column. - # * `options` (optional) {Object} with the following keys: - # * `autoscroll` {Boolean} indicating whether to autoscroll to the new - # position. Defaults to `true` if this is the most recently added cursor, - # `false` otherwise. - setBufferPosition: (bufferPosition, options={}) -> - @changePosition options, => - @marker.setHeadBufferPosition(bufferPosition, options) - - # Public: Returns the current buffer position as an Array. - getBufferPosition: -> - @marker.getHeadBufferPosition() - - # Public: Returns the cursor's current screen row. - getScreenRow: -> - @getScreenPosition().row - - # Public: Returns the cursor's current screen column. - getScreenColumn: -> - @getScreenPosition().column - - # Public: Retrieves the cursor's current buffer row. - getBufferRow: -> - @getBufferPosition().row - - # Public: Returns the cursor's current buffer column. - getBufferColumn: -> - @getBufferPosition().column - - # Public: Returns the cursor's current buffer row of text excluding its line - # ending. - getCurrentBufferLine: -> - @editor.lineTextForBufferRow(@getBufferRow()) - - # Public: Returns whether the cursor is at the start of a line. - isAtBeginningOfLine: -> - @getBufferPosition().column is 0 - - # Public: Returns whether the cursor is on the line return character. - isAtEndOfLine: -> - @getBufferPosition().isEqual(@getCurrentLineBufferRange().end) - - ### - Section: Cursor Position Details - ### - - # Public: Returns the underlying {DisplayMarker} for the cursor. - # Useful with overlay {Decoration}s. - getMarker: -> @marker - - # Public: Identifies if the cursor is surrounded by whitespace. - # - # "Surrounded" here means that the character directly before and after the - # cursor are both whitespace. - # - # Returns a {Boolean}. - isSurroundedByWhitespace: -> - {row, column} = @getBufferPosition() - range = [[row, column - 1], [row, column + 1]] - /^\s+$/.test @editor.getTextInBufferRange(range) - - # Public: Returns whether the cursor is currently between a word and non-word - # character. The non-word characters are defined by the - # `editor.nonWordCharacters` config value. - # - # This method returns false if the character before or after the cursor is - # whitespace. - # - # Returns a Boolean. - isBetweenWordAndNonWord: -> - return false if @isAtBeginningOfLine() or @isAtEndOfLine() - - {row, column} = @getBufferPosition() - range = [[row, column - 1], [row, column + 1]] - [before, after] = @editor.getTextInBufferRange(range) - return false if /\s/.test(before) or /\s/.test(after) - - nonWordCharacters = @getNonWordCharacters() - nonWordCharacters.includes(before) isnt nonWordCharacters.includes(after) - - # Public: Returns whether this cursor is between a word's start and end. - # - # * `options` (optional) {Object} - # * `wordRegex` A {RegExp} indicating what constitutes a "word" - # (default: {::wordRegExp}). - # - # Returns a {Boolean} - isInsideWord: (options) -> - {row, column} = @getBufferPosition() - range = [[row, column], [row, Infinity]] - @editor.getTextInBufferRange(range).search(options?.wordRegex ? @wordRegExp()) is 0 - - # Public: Returns the indentation level of the current line. - getIndentLevel: -> - if @editor.getSoftTabs() - @getBufferColumn() / @editor.getTabLength() - else - @getBufferColumn() - - # Public: Retrieves the scope descriptor for the cursor's current position. - # - # Returns a {ScopeDescriptor} - getScopeDescriptor: -> - @editor.scopeDescriptorForBufferPosition(@getBufferPosition()) - - # Public: Returns true if this cursor has no non-whitespace characters before - # its current position. - hasPrecedingCharactersOnLine: -> - bufferPosition = @getBufferPosition() - line = @editor.lineTextForBufferRow(bufferPosition.row) - firstCharacterColumn = line.search(/\S/) - - if firstCharacterColumn is -1 - false - else - bufferPosition.column > firstCharacterColumn - - # Public: Identifies if this cursor is the last in the {TextEditor}. - # - # "Last" is defined as the most recently added cursor. - # - # Returns a {Boolean}. - isLastCursor: -> - this is @editor.getLastCursor() - - ### - Section: Moving the Cursor - ### - - # Public: Moves the cursor up one screen row. - # - # * `rowCount` (optional) {Number} number of rows to move (default: 1) - # * `options` (optional) {Object} with the following keys: - # * `moveToEndOfSelection` if true, move to the left of the selection if a - # selection exists. - moveUp: (rowCount=1, {moveToEndOfSelection}={}) -> - range = @marker.getScreenRange() - if moveToEndOfSelection and not range.isEmpty() - {row, column} = range.start - else - {row, column} = @getScreenPosition() - - column = @goalColumn if @goalColumn? - @setScreenPosition({row: row - rowCount, column: column}, skipSoftWrapIndentation: true) - @goalColumn = column - - # Public: Moves the cursor down one screen row. - # - # * `rowCount` (optional) {Number} number of rows to move (default: 1) - # * `options` (optional) {Object} with the following keys: - # * `moveToEndOfSelection` if true, move to the left of the selection if a - # selection exists. - moveDown: (rowCount=1, {moveToEndOfSelection}={}) -> - range = @marker.getScreenRange() - if moveToEndOfSelection and not range.isEmpty() - {row, column} = range.end - else - {row, column} = @getScreenPosition() - - column = @goalColumn if @goalColumn? - @setScreenPosition({row: row + rowCount, column: column}, skipSoftWrapIndentation: true) - @goalColumn = column - - # Public: Moves the cursor left one screen column. - # - # * `columnCount` (optional) {Number} number of columns to move (default: 1) - # * `options` (optional) {Object} with the following keys: - # * `moveToEndOfSelection` if true, move to the left of the selection if a - # selection exists. - moveLeft: (columnCount=1, {moveToEndOfSelection}={}) -> - range = @marker.getScreenRange() - if moveToEndOfSelection and not range.isEmpty() - @setScreenPosition(range.start) - else - {row, column} = @getScreenPosition() - - while columnCount > column and row > 0 - columnCount -= column - column = @editor.lineLengthForScreenRow(--row) - columnCount-- # subtract 1 for the row move - - column = column - columnCount - @setScreenPosition({row, column}, clipDirection: 'backward') - - # Public: Moves the cursor right one screen column. - # - # * `columnCount` (optional) {Number} number of columns to move (default: 1) - # * `options` (optional) {Object} with the following keys: - # * `moveToEndOfSelection` if true, move to the right of the selection if a - # selection exists. - moveRight: (columnCount=1, {moveToEndOfSelection}={}) -> - range = @marker.getScreenRange() - if moveToEndOfSelection and not range.isEmpty() - @setScreenPosition(range.end) - else - {row, column} = @getScreenPosition() - maxLines = @editor.getScreenLineCount() - rowLength = @editor.lineLengthForScreenRow(row) - columnsRemainingInLine = rowLength - column - - while columnCount > columnsRemainingInLine and row < maxLines - 1 - columnCount -= columnsRemainingInLine - columnCount-- # subtract 1 for the row move - - column = 0 - rowLength = @editor.lineLengthForScreenRow(++row) - columnsRemainingInLine = rowLength - - column = column + columnCount - @setScreenPosition({row, column}, clipDirection: 'forward') - - # Public: Moves the cursor to the top of the buffer. - moveToTop: -> - @setBufferPosition([0, 0]) - - # Public: Moves the cursor to the bottom of the buffer. - moveToBottom: -> - @setBufferPosition(@editor.getEofBufferPosition()) - - # Public: Moves the cursor to the beginning of the line. - moveToBeginningOfScreenLine: -> - @setScreenPosition([@getScreenRow(), 0]) - - # Public: Moves the cursor to the beginning of the buffer line. - moveToBeginningOfLine: -> - @setBufferPosition([@getBufferRow(), 0]) - - # Public: Moves the cursor to the beginning of the first character in the - # line. - moveToFirstCharacterOfLine: -> - screenRow = @getScreenRow() - screenLineStart = @editor.clipScreenPosition([screenRow, 0], skipSoftWrapIndentation: true) - screenLineEnd = [screenRow, Infinity] - screenLineBufferRange = @editor.bufferRangeForScreenRange([screenLineStart, screenLineEnd]) - - firstCharacterColumn = null - @editor.scanInBufferRange /\S/, screenLineBufferRange, ({range, stop}) -> - firstCharacterColumn = range.start.column - stop() - - if firstCharacterColumn? and firstCharacterColumn isnt @getBufferColumn() - targetBufferColumn = firstCharacterColumn - else - targetBufferColumn = screenLineBufferRange.start.column - - @setBufferPosition([screenLineBufferRange.start.row, targetBufferColumn]) - - # Public: Moves the cursor to the end of the line. - moveToEndOfScreenLine: -> - @setScreenPosition([@getScreenRow(), Infinity]) - - # Public: Moves the cursor to the end of the buffer line. - moveToEndOfLine: -> - @setBufferPosition([@getBufferRow(), Infinity]) - - # Public: Moves the cursor to the beginning of the word. - moveToBeginningOfWord: -> - @setBufferPosition(@getBeginningOfCurrentWordBufferPosition()) - - # Public: Moves the cursor to the end of the word. - moveToEndOfWord: -> - if position = @getEndOfCurrentWordBufferPosition() - @setBufferPosition(position) - - # Public: Moves the cursor to the beginning of the next word. - moveToBeginningOfNextWord: -> - if position = @getBeginningOfNextWordBufferPosition() - @setBufferPosition(position) - - # Public: Moves the cursor to the previous word boundary. - moveToPreviousWordBoundary: -> - if position = @getPreviousWordBoundaryBufferPosition() - @setBufferPosition(position) - - # Public: Moves the cursor to the next word boundary. - moveToNextWordBoundary: -> - if position = @getNextWordBoundaryBufferPosition() - @setBufferPosition(position) - - # Public: Moves the cursor to the previous subword boundary. - moveToPreviousSubwordBoundary: -> - options = {wordRegex: @subwordRegExp(backwards: true)} - if position = @getPreviousWordBoundaryBufferPosition(options) - @setBufferPosition(position) - - # Public: Moves the cursor to the next subword boundary. - moveToNextSubwordBoundary: -> - options = {wordRegex: @subwordRegExp()} - if position = @getNextWordBoundaryBufferPosition(options) - @setBufferPosition(position) - - # Public: Moves the cursor to the beginning of the buffer line, skipping all - # whitespace. - skipLeadingWhitespace: -> - position = @getBufferPosition() - scanRange = @getCurrentLineBufferRange() - endOfLeadingWhitespace = null - @editor.scanInBufferRange /^[ \t]*/, scanRange, ({range}) -> - endOfLeadingWhitespace = range.end - - @setBufferPosition(endOfLeadingWhitespace) if endOfLeadingWhitespace.isGreaterThan(position) - - # Public: Moves the cursor to the beginning of the next paragraph - moveToBeginningOfNextParagraph: -> - if position = @getBeginningOfNextParagraphBufferPosition() - @setBufferPosition(position) - - # Public: Moves the cursor to the beginning of the previous paragraph - moveToBeginningOfPreviousParagraph: -> - if position = @getBeginningOfPreviousParagraphBufferPosition() - @setBufferPosition(position) - - ### - Section: Local Positions and Ranges - ### - - # Public: Returns buffer position of previous word boundary. It might be on - # the current word, or the previous word. - # - # * `options` (optional) {Object} with the following keys: - # * `wordRegex` A {RegExp} indicating what constitutes a "word" - # (default: {::wordRegExp}) - getPreviousWordBoundaryBufferPosition: (options = {}) -> - currentBufferPosition = @getBufferPosition() - previousNonBlankRow = @editor.buffer.previousNonBlankRow(currentBufferPosition.row) - scanRange = [[previousNonBlankRow ? 0, 0], currentBufferPosition] - - beginningOfWordPosition = null - @editor.backwardsScanInBufferRange (options.wordRegex ? @wordRegExp()), scanRange, ({range, stop}) -> - if range.start.row < currentBufferPosition.row and currentBufferPosition.column > 0 - # force it to stop at the beginning of each line - beginningOfWordPosition = new Point(currentBufferPosition.row, 0) - else if range.end.isLessThan(currentBufferPosition) - beginningOfWordPosition = range.end - else - beginningOfWordPosition = range.start - - if not beginningOfWordPosition?.isEqual(currentBufferPosition) - stop() - - beginningOfWordPosition or currentBufferPosition - - # Public: Returns buffer position of the next word boundary. It might be on - # the current word, or the previous word. - # - # * `options` (optional) {Object} with the following keys: - # * `wordRegex` A {RegExp} indicating what constitutes a "word" - # (default: {::wordRegExp}) - getNextWordBoundaryBufferPosition: (options = {}) -> - currentBufferPosition = @getBufferPosition() - scanRange = [currentBufferPosition, @editor.getEofBufferPosition()] - - endOfWordPosition = null - @editor.scanInBufferRange (options.wordRegex ? @wordRegExp()), scanRange, ({range, stop}) -> - if range.start.row > currentBufferPosition.row - # force it to stop at the beginning of each line - endOfWordPosition = new Point(range.start.row, 0) - else if range.start.isGreaterThan(currentBufferPosition) - endOfWordPosition = range.start - else - endOfWordPosition = range.end - - if not endOfWordPosition?.isEqual(currentBufferPosition) - stop() - - endOfWordPosition or currentBufferPosition - - # Public: Retrieves the buffer position of where the current word starts. - # - # * `options` (optional) An {Object} with the following keys: - # * `wordRegex` A {RegExp} indicating what constitutes a "word" - # (default: {::wordRegExp}). - # * `includeNonWordCharacters` A {Boolean} indicating whether to include - # non-word characters in the default word regex. - # Has no effect if wordRegex is set. - # * `allowPrevious` A {Boolean} indicating whether the beginning of the - # previous word can be returned. - # - # Returns a {Range}. - getBeginningOfCurrentWordBufferPosition: (options = {}) -> - allowPrevious = options.allowPrevious ? true - currentBufferPosition = @getBufferPosition() - previousNonBlankRow = @editor.buffer.previousNonBlankRow(currentBufferPosition.row) ? 0 - scanRange = [[previousNonBlankRow, 0], currentBufferPosition] - - beginningOfWordPosition = null - @editor.backwardsScanInBufferRange (options.wordRegex ? @wordRegExp(options)), scanRange, ({range, matchText, stop}) -> - # Ignore 'empty line' matches between '\r' and '\n' - return if matchText is '' and range.start.column isnt 0 - - if range.start.isLessThan(currentBufferPosition) - if range.end.isGreaterThanOrEqual(currentBufferPosition) or allowPrevious - beginningOfWordPosition = range.start - stop() - - if beginningOfWordPosition? - beginningOfWordPosition - else if allowPrevious - new Point(0, 0) - else - currentBufferPosition - - # Public: Retrieves the buffer position of where the current word ends. - # - # * `options` (optional) {Object} with the following keys: - # * `wordRegex` A {RegExp} indicating what constitutes a "word" - # (default: {::wordRegExp}) - # * `includeNonWordCharacters` A Boolean indicating whether to include - # non-word characters in the default word regex. Has no effect if - # wordRegex is set. - # - # Returns a {Range}. - getEndOfCurrentWordBufferPosition: (options = {}) -> - allowNext = options.allowNext ? true - currentBufferPosition = @getBufferPosition() - scanRange = [currentBufferPosition, @editor.getEofBufferPosition()] - - endOfWordPosition = null - @editor.scanInBufferRange (options.wordRegex ? @wordRegExp(options)), scanRange, ({range, matchText, stop}) -> - # Ignore 'empty line' matches between '\r' and '\n' - return if matchText is '' and range.start.column isnt 0 - - if range.end.isGreaterThan(currentBufferPosition) - if allowNext or range.start.isLessThanOrEqual(currentBufferPosition) - endOfWordPosition = range.end - stop() - - endOfWordPosition ? currentBufferPosition - - # Public: Retrieves the buffer position of where the next word starts. - # - # * `options` (optional) {Object} - # * `wordRegex` A {RegExp} indicating what constitutes a "word" - # (default: {::wordRegExp}). - # - # Returns a {Range} - getBeginningOfNextWordBufferPosition: (options = {}) -> - currentBufferPosition = @getBufferPosition() - start = if @isInsideWord(options) then @getEndOfCurrentWordBufferPosition(options) else currentBufferPosition - scanRange = [start, @editor.getEofBufferPosition()] - - beginningOfNextWordPosition = null - @editor.scanInBufferRange (options.wordRegex ? @wordRegExp()), scanRange, ({range, stop}) -> - beginningOfNextWordPosition = range.start - stop() - - beginningOfNextWordPosition or currentBufferPosition - - # Public: Returns the buffer Range occupied by the word located under the cursor. - # - # * `options` (optional) {Object} - # * `wordRegex` A {RegExp} indicating what constitutes a "word" - # (default: {::wordRegExp}). - getCurrentWordBufferRange: (options={}) -> - startOptions = Object.assign(_.clone(options), allowPrevious: false) - endOptions = Object.assign(_.clone(options), allowNext: false) - new Range(@getBeginningOfCurrentWordBufferPosition(startOptions), @getEndOfCurrentWordBufferPosition(endOptions)) - - # Public: Returns the buffer Range for the current line. - # - # * `options` (optional) {Object} - # * `includeNewline` A {Boolean} which controls whether the Range should - # include the newline. - getCurrentLineBufferRange: (options) -> - @editor.bufferRangeForBufferRow(@getBufferRow(), options) - - # Public: Retrieves the range for the current paragraph. - # - # A paragraph is defined as a block of text surrounded by empty lines or comments. - # - # Returns a {Range}. - getCurrentParagraphBufferRange: -> - @editor.languageMode.rowRangeForParagraphAtBufferRow(@getBufferRow()) - - # Public: Returns the characters preceding the cursor in the current word. - getCurrentWordPrefix: -> - @editor.getTextInBufferRange([@getBeginningOfCurrentWordBufferPosition(), @getBufferPosition()]) - - ### - Section: Visibility - ### - - ### - Section: Comparing to another cursor - ### - - # Public: Compare this cursor's buffer position to another cursor's buffer position. - # - # See {Point::compare} for more details. - # - # * `otherCursor`{Cursor} to compare against - compare: (otherCursor) -> - @getBufferPosition().compare(otherCursor.getBufferPosition()) - - ### - Section: Utilities - ### - - # Public: Deselects the current selection. - clearSelection: (options) -> - @selection?.clear(options) - - # Public: Get the RegExp used by the cursor to determine what a "word" is. - # - # * `options` (optional) {Object} with the following keys: - # * `includeNonWordCharacters` A {Boolean} indicating whether to include - # non-word characters in the regex. (default: true) - # - # Returns a {RegExp}. - wordRegExp: (options) -> - nonWordCharacters = _.escapeRegExp(@getNonWordCharacters()) - source = "^[\t ]*$|[^\\s#{nonWordCharacters}]+" - if options?.includeNonWordCharacters ? true - source += "|" + "[#{nonWordCharacters}]+" - new RegExp(source, "g") - - # Public: Get the RegExp used by the cursor to determine what a "subword" is. - # - # * `options` (optional) {Object} with the following keys: - # * `backwards` A {Boolean} indicating whether to look forwards or backwards - # for the next subword. (default: false) - # - # Returns a {RegExp}. - subwordRegExp: (options={}) -> - nonWordCharacters = @getNonWordCharacters() - lowercaseLetters = 'a-z\\u00DF-\\u00F6\\u00F8-\\u00FF' - uppercaseLetters = 'A-Z\\u00C0-\\u00D6\\u00D8-\\u00DE' - snakeCamelSegment = "[#{uppercaseLetters}]?[#{lowercaseLetters}]+" - segments = [ - "^[\t ]+", - "[\t ]+$", - "[#{uppercaseLetters}]+(?![#{lowercaseLetters}])", - "\\d+" - ] - if options.backwards - segments.push("#{snakeCamelSegment}_*") - segments.push("[#{_.escapeRegExp(nonWordCharacters)}]+\\s*") - else - segments.push("_*#{snakeCamelSegment}") - segments.push("\\s*[#{_.escapeRegExp(nonWordCharacters)}]+") - segments.push("_+") - new RegExp(segments.join("|"), "g") - - ### - Section: Private - ### - - getNonWordCharacters: -> - @editor.getNonWordCharacters(@getScopeDescriptor().getScopesArray()) - - changePosition: (options, fn) -> - @clearSelection(autoscroll: false) - fn() - @autoscroll() if options.autoscroll ? @isLastCursor() - - getScreenRange: -> - {row, column} = @getScreenPosition() - new Range(new Point(row, column), new Point(row, column + 1)) - - autoscroll: (options = {}) -> - options.clip = false - @editor.scrollToScreenRange(@getScreenRange(), options) - - getBeginningOfNextParagraphBufferPosition: -> - start = @getBufferPosition() - eof = @editor.getEofBufferPosition() - scanRange = [start, eof] - - {row, column} = eof - position = new Point(row, column - 1) - - @editor.scanInBufferRange EmptyLineRegExp, scanRange, ({range, stop}) -> - position = range.start.traverse(Point(1, 0)) - stop() unless position.isEqual(start) - position - - getBeginningOfPreviousParagraphBufferPosition: -> - start = @getBufferPosition() - - {row, column} = start - scanRange = [[row-1, column], [0, 0]] - position = new Point(0, 0) - @editor.backwardsScanInBufferRange EmptyLineRegExp, scanRange, ({range, stop}) -> - position = range.start.traverse(Point(1, 0)) - stop() unless position.isEqual(start) - position diff --git a/src/cursor.js b/src/cursor.js new file mode 100644 index 000000000..1425f5b49 --- /dev/null +++ b/src/cursor.js @@ -0,0 +1,753 @@ +const {Point, Range} = require('text-buffer') +const {Emitter} = require('event-kit') +const _ = require('underscore-plus') +const Model = require('./model') + +const EmptyLineRegExp = /(\r\n[\t ]*\r\n)|(\n[\t ]*\n)/g + +// Extended: The `Cursor` class represents the little blinking line identifying +// where text can be inserted. +// +// Cursors belong to {TextEditor}s and have some metadata attached in the form +// of a {DisplayMarker}. +module.exports = +class Cursor extends Model { + // Instantiated by a {TextEditor} + constructor (params) { + super(params) + this.editor = params.editor + this.marker = params.marker + this.emitter = new Emitter() + } + + destroy () { + this.marker.destroy() + } + + /* + Section: Event Subscription + */ + + // Public: Calls your `callback` when the cursor has been moved. + // + // * `callback` {Function} + // * `event` {Object} + // * `oldBufferPosition` {Point} + // * `oldScreenPosition` {Point} + // * `newBufferPosition` {Point} + // * `newScreenPosition` {Point} + // * `textChanged` {Boolean} + // * `cursor` {Cursor} that triggered the event + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidChangePosition (callback) { + return this.emitter.on('did-change-position', callback) + } + + // Public: Calls your `callback` when the cursor is destroyed + // + // * `callback` {Function} + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidDestroy (callback) { + return this.emitter.once('did-destroy', callback) + } + + /* + Section: Managing Cursor Position + */ + + // Public: Moves a cursor to a given screen position. + // + // * `screenPosition` {Array} of two numbers: the screen row, and the screen column. + // * `options` (optional) {Object} with the following keys: + // * `autoscroll` A Boolean which, if `true`, scrolls the {TextEditor} to wherever + // the cursor moves to. + setScreenPosition (screenPosition, options = {}) { + this.changePosition(options, () => { + this.marker.setHeadScreenPosition(screenPosition, options) + }) + } + + // Public: Returns the screen position of the cursor as a {Point}. + getScreenPosition () { + return this.marker.getHeadScreenPosition() + } + + // Public: Moves a cursor to a given buffer position. + // + // * `bufferPosition` {Array} of two numbers: the buffer row, and the buffer column. + // * `options` (optional) {Object} with the following keys: + // * `autoscroll` {Boolean} indicating whether to autoscroll to the new + // position. Defaults to `true` if this is the most recently added cursor, + // `false` otherwise. + setBufferPosition (bufferPosition, options = {}) { + this.changePosition(options, () => { + this.marker.setHeadBufferPosition(bufferPosition, options) + }) + } + + // Public: Returns the current buffer position as an Array. + getBufferPosition () { + return this.marker.getHeadBufferPosition() + } + + // Public: Returns the cursor's current screen row. + getScreenRow () { + return this.getScreenPosition().row + } + + // Public: Returns the cursor's current screen column. + getScreenColumn () { + return this.getScreenPosition().column + } + + // Public: Retrieves the cursor's current buffer row. + getBufferRow () { + return this.getBufferPosition().row + } + + // Public: Returns the cursor's current buffer column. + getBufferColumn () { + return this.getBufferPosition().column + } + + // Public: Returns the cursor's current buffer row of text excluding its line + // ending. + getCurrentBufferLine () { + return this.editor.lineTextForBufferRow(this.getBufferRow()) + } + + // Public: Returns whether the cursor is at the start of a line. + isAtBeginningOfLine () { + return this.getBufferPosition().column === 0 + } + + // Public: Returns whether the cursor is on the line return character. + isAtEndOfLine () { + return this.getBufferPosition().isEqual(this.getCurrentLineBufferRange().end) + } + + /* + Section: Cursor Position Details + */ + + // Public: Returns the underlying {DisplayMarker} for the cursor. + // Useful with overlay {Decoration}s. + getMarker () { return this.marker } + + // Public: Identifies if the cursor is surrounded by whitespace. + // + // "Surrounded" here means that the character directly before and after the + // cursor are both whitespace. + // + // Returns a {Boolean}. + isSurroundedByWhitespace () { + const {row, column} = this.getBufferPosition() + const range = [[row, column - 1], [row, column + 1]] + return /^\s+$/.test(this.editor.getTextInBufferRange(range)) + } + + // Public: Returns whether the cursor is currently between a word and non-word + // character. The non-word characters are defined by the + // `editor.nonWordCharacters` config value. + // + // This method returns false if the character before or after the cursor is + // whitespace. + // + // Returns a Boolean. + isBetweenWordAndNonWord () { + if (this.isAtBeginningOfLine() || this.isAtEndOfLine()) return false + + const {row, column} = this.getBufferPosition() + const range = [[row, column - 1], [row, column + 1]] + const text = this.editor.getTextInBufferRange(range) + if (/\s/.test(text[0]) || /\s/.test(text[1])) return false + + const nonWordCharacters = this.getNonWordCharacters() + return nonWordCharacters.includes(text[0]) !== nonWordCharacters.includes(text[1]) + } + + // Public: Returns whether this cursor is between a word's start and end. + // + // * `options` (optional) {Object} + // * `wordRegex` A {RegExp} indicating what constitutes a "word" + // (default: {::wordRegExp}). + // + // Returns a {Boolean} + isInsideWord (options) { + const {row, column} = this.getBufferPosition() + const range = [[row, column], [row, Infinity]] + const text = this.editor.getTextInBufferRange(range) + return text.search((options && options.wordRegex) || this.wordRegExp()) === 0 + } + + // Public: Returns the indentation level of the current line. + getIndentLevel () { + if (this.editor.getSoftTabs()) { + return this.getBufferColumn() / this.editor.getTabLength() + } else { + return this.getBufferColumn() + } + } + + // Public: Retrieves the scope descriptor for the cursor's current position. + // + // Returns a {ScopeDescriptor} + getScopeDescriptor () { + return this.editor.scopeDescriptorForBufferPosition(this.getBufferPosition()) + } + + // Public: Returns true if this cursor has no non-whitespace characters before + // its current position. + hasPrecedingCharactersOnLine () { + const bufferPosition = this.getBufferPosition() + const line = this.editor.lineTextForBufferRow(bufferPosition.row) + const firstCharacterColumn = line.search(/\S/) + + if (firstCharacterColumn === -1) { + return false + } else { + return bufferPosition.column > firstCharacterColumn + } + } + + // Public: Identifies if this cursor is the last in the {TextEditor}. + // + // "Last" is defined as the most recently added cursor. + // + // Returns a {Boolean}. + isLastCursor () { + return this === this.editor.getLastCursor() + } + + /* + Section: Moving the Cursor + */ + + // Public: Moves the cursor up one screen row. + // + // * `rowCount` (optional) {Number} number of rows to move (default: 1) + // * `options` (optional) {Object} with the following keys: + // * `moveToEndOfSelection` if true, move to the left of the selection if a + // selection exists. + moveUp (rowCount = 1, {moveToEndOfSelection} = {}) { + let row, column + const range = this.marker.getScreenRange() + if (moveToEndOfSelection && !range.isEmpty()) { + ({row, column} = range.start) + } else { + ({row, column} = this.getScreenPosition()) + } + + if (this.goalColumn != null) column = this.goalColumn + this.setScreenPosition({row: row - rowCount, column}, {skipSoftWrapIndentation: true}) + this.goalColumn = column + } + + // Public: Moves the cursor down one screen row. + // + // * `rowCount` (optional) {Number} number of rows to move (default: 1) + // * `options` (optional) {Object} with the following keys: + // * `moveToEndOfSelection` if true, move to the left of the selection if a + // selection exists. + moveDown (rowCount = 1, {moveToEndOfSelection} = {}) { + let row, column + const range = this.marker.getScreenRange() + if (moveToEndOfSelection && !range.isEmpty()) { + ({row, column} = range.end) + } else { + ({row, column} = this.getScreenPosition()) + } + + if (this.goalColumn != null) column = this.goalColumn + this.setScreenPosition({row: row + rowCount, column}, {skipSoftWrapIndentation: true}) + this.goalColumn = column + } + + // Public: Moves the cursor left one screen column. + // + // * `columnCount` (optional) {Number} number of columns to move (default: 1) + // * `options` (optional) {Object} with the following keys: + // * `moveToEndOfSelection` if true, move to the left of the selection if a + // selection exists. + moveLeft (columnCount = 1, {moveToEndOfSelection} = {}) { + const range = this.marker.getScreenRange() + if (moveToEndOfSelection && !range.isEmpty()) { + this.setScreenPosition(range.start) + } else { + let {row, column} = this.getScreenPosition() + + while (columnCount > column && row > 0) { + columnCount -= column + column = this.editor.lineLengthForScreenRow(--row) + columnCount-- // subtract 1 for the row move + } + + column = column - columnCount + this.setScreenPosition({row, column}, {clipDirection: 'backward'}) + } + } + + // Public: Moves the cursor right one screen column. + // + // * `columnCount` (optional) {Number} number of columns to move (default: 1) + // * `options` (optional) {Object} with the following keys: + // * `moveToEndOfSelection` if true, move to the right of the selection if a + // selection exists. + moveRight (columnCount = 1, {moveToEndOfSelection} = {}) { + const range = this.marker.getScreenRange() + if (moveToEndOfSelection && !range.isEmpty()) { + this.setScreenPosition(range.end) + } else { + let {row, column} = this.getScreenPosition() + const maxLines = this.editor.getScreenLineCount() + let rowLength = this.editor.lineLengthForScreenRow(row) + let columnsRemainingInLine = rowLength - column + + while (columnCount > columnsRemainingInLine && row < maxLines - 1) { + columnCount -= columnsRemainingInLine + columnCount-- // subtract 1 for the row move + + column = 0 + rowLength = this.editor.lineLengthForScreenRow(++row) + columnsRemainingInLine = rowLength + } + + column = column + columnCount + this.setScreenPosition({row, column}, {clipDirection: 'forward'}) + } + } + + // Public: Moves the cursor to the top of the buffer. + moveToTop () { + this.setBufferPosition([0, 0]) + } + + // Public: Moves the cursor to the bottom of the buffer. + moveToBottom () { + this.setBufferPosition(this.editor.getEofBufferPosition()) + } + + // Public: Moves the cursor to the beginning of the line. + moveToBeginningOfScreenLine () { + this.setScreenPosition([this.getScreenRow(), 0]) + } + + // Public: Moves the cursor to the beginning of the buffer line. + moveToBeginningOfLine () { + this.setBufferPosition([this.getBufferRow(), 0]) + } + + // Public: Moves the cursor to the beginning of the first character in the + // line. + moveToFirstCharacterOfLine () { + let targetBufferColumn + const screenRow = this.getScreenRow() + const screenLineStart = this.editor.clipScreenPosition([screenRow, 0], {skipSoftWrapIndentation: true}) + const screenLineEnd = [screenRow, Infinity] + const screenLineBufferRange = this.editor.bufferRangeForScreenRange([screenLineStart, screenLineEnd]) + + let firstCharacterColumn = null + this.editor.scanInBufferRange(/\S/, screenLineBufferRange, ({range, stop}) => { + firstCharacterColumn = range.start.column + stop() + }) + + if (firstCharacterColumn != null && firstCharacterColumn !== this.getBufferColumn()) { + targetBufferColumn = firstCharacterColumn + } else { + targetBufferColumn = screenLineBufferRange.start.column + } + + this.setBufferPosition([screenLineBufferRange.start.row, targetBufferColumn]) + } + + // Public: Moves the cursor to the end of the line. + moveToEndOfScreenLine () { + this.setScreenPosition([this.getScreenRow(), Infinity]) + } + + // Public: Moves the cursor to the end of the buffer line. + moveToEndOfLine () { + this.setBufferPosition([this.getBufferRow(), Infinity]) + } + + // Public: Moves the cursor to the beginning of the word. + moveToBeginningOfWord () { + this.setBufferPosition(this.getBeginningOfCurrentWordBufferPosition()) + } + + // Public: Moves the cursor to the end of the word. + moveToEndOfWord () { + const position = this.getEndOfCurrentWordBufferPosition() + if (position) this.setBufferPosition(position) + } + + // Public: Moves the cursor to the beginning of the next word. + moveToBeginningOfNextWord () { + const position = this.getBeginningOfNextWordBufferPosition() + if (position) this.setBufferPosition(position) + } + + // Public: Moves the cursor to the previous word boundary. + moveToPreviousWordBoundary () { + const position = this.getPreviousWordBoundaryBufferPosition() + if (position) this.setBufferPosition(position) + } + + // Public: Moves the cursor to the next word boundary. + moveToNextWordBoundary () { + const position = this.getNextWordBoundaryBufferPosition() + if (position) this.setBufferPosition(position) + } + + // Public: Moves the cursor to the previous subword boundary. + moveToPreviousSubwordBoundary () { + const options = {wordRegex: this.subwordRegExp({backwards: true})} + const position = this.getPreviousWordBoundaryBufferPosition(options) + if (position) this.setBufferPosition(position) + } + + // Public: Moves the cursor to the next subword boundary. + moveToNextSubwordBoundary () { + const options = {wordRegex: this.subwordRegExp()} + const position = this.getNextWordBoundaryBufferPosition(options) + if (position) this.setBufferPosition(position) + } + + // Public: Moves the cursor to the beginning of the buffer line, skipping all + // whitespace. + skipLeadingWhitespace () { + const position = this.getBufferPosition() + const scanRange = this.getCurrentLineBufferRange() + let endOfLeadingWhitespace = null + this.editor.scanInBufferRange(/^[ \t]*/, scanRange, ({range}) => { + endOfLeadingWhitespace = range.end + }) + + if (endOfLeadingWhitespace.isGreaterThan(position)) this.setBufferPosition(endOfLeadingWhitespace) + } + + // Public: Moves the cursor to the beginning of the next paragraph + moveToBeginningOfNextParagraph () { + const position = this.getBeginningOfNextParagraphBufferPosition() + if (position) this.setBufferPosition(position) + } + + // Public: Moves the cursor to the beginning of the previous paragraph + moveToBeginningOfPreviousParagraph () { + const position = this.getBeginningOfPreviousParagraphBufferPosition() + if (position) this.setBufferPosition(position) + } + + /* + Section: Local Positions and Ranges + */ + + // Public: Returns buffer position of previous word boundary. It might be on + // the current word, or the previous word. + // + // * `options` (optional) {Object} with the following keys: + // * `wordRegex` A {RegExp} indicating what constitutes a "word" + // (default: {::wordRegExp}) + getPreviousWordBoundaryBufferPosition (options = {}) { + const currentBufferPosition = this.getBufferPosition() + const previousNonBlankRow = this.editor.buffer.previousNonBlankRow(currentBufferPosition.row) + const scanRange = [[previousNonBlankRow || 0, 0], currentBufferPosition] + + let beginningOfWordPosition + this.editor.backwardsScanInBufferRange(options.wordRegex || this.wordRegExp(), scanRange, ({range, stop}) => { + if (range.start.row < currentBufferPosition.row && currentBufferPosition.column > 0) { + // force it to stop at the beginning of each line + beginningOfWordPosition = new Point(currentBufferPosition.row, 0) + } else if (range.end.isLessThan(currentBufferPosition)) { + beginningOfWordPosition = range.end + } else { + beginningOfWordPosition = range.start + } + + if (!beginningOfWordPosition.isEqual(currentBufferPosition)) stop() + }) + + return beginningOfWordPosition || currentBufferPosition + } + + // Public: Returns buffer position of the next word boundary. It might be on + // the current word, or the previous word. + // + // * `options` (optional) {Object} with the following keys: + // * `wordRegex` A {RegExp} indicating what constitutes a "word" + // (default: {::wordRegExp}) + getNextWordBoundaryBufferPosition (options = {}) { + const currentBufferPosition = this.getBufferPosition() + const scanRange = [currentBufferPosition, this.editor.getEofBufferPosition()] + + let endOfWordPosition + this.editor.scanInBufferRange((options.wordRegex != null ? options.wordRegex : this.wordRegExp()), scanRange, function ({range, stop}) { + if (range.start.row > currentBufferPosition.row) { + // force it to stop at the beginning of each line + endOfWordPosition = new Point(range.start.row, 0) + } else if (range.start.isGreaterThan(currentBufferPosition)) { + endOfWordPosition = range.start + } else { + endOfWordPosition = range.end + } + + if (!endOfWordPosition.isEqual(currentBufferPosition)) stop() + }) + + return endOfWordPosition || currentBufferPosition + } + + // Public: Retrieves the buffer position of where the current word starts. + // + // * `options` (optional) An {Object} with the following keys: + // * `wordRegex` A {RegExp} indicating what constitutes a "word" + // (default: {::wordRegExp}). + // * `includeNonWordCharacters` A {Boolean} indicating whether to include + // non-word characters in the default word regex. + // Has no effect if wordRegex is set. + // * `allowPrevious` A {Boolean} indicating whether the beginning of the + // previous word can be returned. + // + // Returns a {Range}. + getBeginningOfCurrentWordBufferPosition (options = {}) { + const allowPrevious = options.allowPrevious !== false + const position = this.getBufferPosition() + + const scanRange = allowPrevious + ? new Range(new Point(position.row - 1, 0), position) + : new Range(new Point(position.row, 0), position) + + const ranges = this.editor.buffer.findAllInRangeSync( + options.wordRegex || this.wordRegExp(), + scanRange + ) + + let result + for (let range of ranges) { + if (position.isLessThanOrEqual(range.start)) break + if (allowPrevious || position.isLessThanOrEqual(range.end)) result = range.start + } + + return result || (allowPrevious ? new Point(0, 0) : position) + } + + // Public: Retrieves the buffer position of where the current word ends. + // + // * `options` (optional) {Object} with the following keys: + // * `wordRegex` A {RegExp} indicating what constitutes a "word" + // (default: {::wordRegExp}) + // * `includeNonWordCharacters` A Boolean indicating whether to include + // non-word characters in the default word regex. Has no effect if + // wordRegex is set. + // + // Returns a {Range}. + getEndOfCurrentWordBufferPosition (options = {}) { + const allowNext = options.allowNext !== false + const position = this.getBufferPosition() + + const scanRange = allowNext + ? new Range(position, new Point(position.row + 2, 0)) + : new Range(position, new Point(position.row, Infinity)) + + const ranges = this.editor.buffer.findAllInRangeSync( + options.wordRegex || this.wordRegExp(), + scanRange + ) + + for (let range of ranges) { + if (position.isLessThan(range.start) && !allowNext) break + if (position.isLessThan(range.end)) return range.end + } + + return allowNext ? this.editor.getEofBufferPosition() : position + } + + // Public: Retrieves the buffer position of where the next word starts. + // + // * `options` (optional) {Object} + // * `wordRegex` A {RegExp} indicating what constitutes a "word" + // (default: {::wordRegExp}). + // + // Returns a {Range} + getBeginningOfNextWordBufferPosition (options = {}) { + const currentBufferPosition = this.getBufferPosition() + const start = this.isInsideWord(options) ? this.getEndOfCurrentWordBufferPosition(options) : currentBufferPosition + const scanRange = [start, this.editor.getEofBufferPosition()] + + let beginningOfNextWordPosition + this.editor.scanInBufferRange(options.wordRegex || this.wordRegExp(), scanRange, ({range, stop}) => { + beginningOfNextWordPosition = range.start + stop() + }) + + return beginningOfNextWordPosition || currentBufferPosition + } + + // Public: Returns the buffer Range occupied by the word located under the cursor. + // + // * `options` (optional) {Object} + // * `wordRegex` A {RegExp} indicating what constitutes a "word" + // (default: {::wordRegExp}). + getCurrentWordBufferRange (options = {}) { + const position = this.getBufferPosition() + const ranges = this.editor.buffer.findAllInRangeSync( + options.wordRegex || this.wordRegExp(), + new Range(new Point(position.row, 0), new Point(position.row, Infinity)) + ) + return ranges.find(range => + range.end.column >= position.column && range.start.column <= position.column + ) || new Range(position, position) + } + + // Public: Returns the buffer Range for the current line. + // + // * `options` (optional) {Object} + // * `includeNewline` A {Boolean} which controls whether the Range should + // include the newline. + getCurrentLineBufferRange (options) { + return this.editor.bufferRangeForBufferRow(this.getBufferRow(), options) + } + + // Public: Retrieves the range for the current paragraph. + // + // A paragraph is defined as a block of text surrounded by empty lines or comments. + // + // Returns a {Range}. + getCurrentParagraphBufferRange () { + return this.editor.rowRangeForParagraphAtBufferRow(this.getBufferRow()) + } + + // Public: Returns the characters preceding the cursor in the current word. + getCurrentWordPrefix () { + return this.editor.getTextInBufferRange([this.getBeginningOfCurrentWordBufferPosition(), this.getBufferPosition()]) + } + + /* + Section: Visibility + */ + + /* + Section: Comparing to another cursor + */ + + // Public: Compare this cursor's buffer position to another cursor's buffer position. + // + // See {Point::compare} for more details. + // + // * `otherCursor`{Cursor} to compare against + compare (otherCursor) { + return this.getBufferPosition().compare(otherCursor.getBufferPosition()) + } + + /* + Section: Utilities + */ + + // Public: Deselects the current selection. + clearSelection (options) { + if (this.selection) this.selection.clear(options) + } + + // Public: Get the RegExp used by the cursor to determine what a "word" is. + // + // * `options` (optional) {Object} with the following keys: + // * `includeNonWordCharacters` A {Boolean} indicating whether to include + // non-word characters in the regex. (default: true) + // + // Returns a {RegExp}. + wordRegExp (options) { + const nonWordCharacters = _.escapeRegExp(this.getNonWordCharacters()) + let source = `^[\t\r ]*$|[^\\s${nonWordCharacters}]+` + if (!options || options.includeNonWordCharacters !== false) { + source += `|${`[${nonWordCharacters}]+`}` + } + return new RegExp(source, 'g') + } + + // Public: Get the RegExp used by the cursor to determine what a "subword" is. + // + // * `options` (optional) {Object} with the following keys: + // * `backwards` A {Boolean} indicating whether to look forwards or backwards + // for the next subword. (default: false) + // + // Returns a {RegExp}. + subwordRegExp (options = {}) { + const nonWordCharacters = this.getNonWordCharacters() + const lowercaseLetters = 'a-z\\u00DF-\\u00F6\\u00F8-\\u00FF' + const uppercaseLetters = 'A-Z\\u00C0-\\u00D6\\u00D8-\\u00DE' + const snakeCamelSegment = `[${uppercaseLetters}]?[${lowercaseLetters}]+` + const segments = [ + '^[\t ]+', + '[\t ]+$', + `[${uppercaseLetters}]+(?![${lowercaseLetters}])`, + '\\d+' + ] + if (options.backwards) { + segments.push(`${snakeCamelSegment}_*`) + segments.push(`[${_.escapeRegExp(nonWordCharacters)}]+\\s*`) + } else { + segments.push(`_*${snakeCamelSegment}`) + segments.push(`\\s*[${_.escapeRegExp(nonWordCharacters)}]+`) + } + segments.push('_+') + return new RegExp(segments.join('|'), 'g') + } + + /* + Section: Private + */ + + getNonWordCharacters () { + return this.editor.getNonWordCharacters(this.getScopeDescriptor().getScopesArray()) + } + + changePosition (options, fn) { + this.clearSelection({autoscroll: false}) + fn() + const autoscroll = (options && options.autoscroll != null) + ? options.autoscroll + : this.isLastCursor() + if (autoscroll) this.autoscroll() + } + + getScreenRange () { + const {row, column} = this.getScreenPosition() + return new Range(new Point(row, column), new Point(row, column + 1)) + } + + autoscroll (options = {}) { + options.clip = false + this.editor.scrollToScreenRange(this.getScreenRange(), options) + } + + getBeginningOfNextParagraphBufferPosition () { + const start = this.getBufferPosition() + const eof = this.editor.getEofBufferPosition() + const scanRange = [start, eof] + + const {row, column} = eof + let position = new Point(row, column - 1) + + this.editor.scanInBufferRange(EmptyLineRegExp, scanRange, ({range, stop}) => { + position = range.start.traverse(Point(1, 0)) + if (!position.isEqual(start)) stop() + }) + return position + } + + getBeginningOfPreviousParagraphBufferPosition () { + const start = this.getBufferPosition() + + const {row, column} = start + const scanRange = [[row - 1, column], [0, 0]] + let position = new Point(0, 0) + this.editor.backwardsScanInBufferRange(EmptyLineRegExp, scanRange, ({range, stop}) => { + position = range.start.traverse(Point(1, 0)) + if (!position.isEqual(start)) stop() + }) + return position + } +} diff --git a/src/git-repository.coffee b/src/git-repository.coffee deleted file mode 100644 index c7105baef..000000000 --- a/src/git-repository.coffee +++ /dev/null @@ -1,496 +0,0 @@ -{join} = require 'path' - -_ = require 'underscore-plus' -{Emitter, Disposable, CompositeDisposable} = require 'event-kit' -fs = require 'fs-plus' -path = require 'path' -GitUtils = require 'git-utils' - -Task = require './task' - -# Extended: Represents the underlying git operations performed by Atom. -# -# This class shouldn't be instantiated directly but instead by accessing the -# `atom.project` global and calling `getRepositories()`. Note that this will -# only be available when the project is backed by a Git repository. -# -# This class handles submodules automatically by taking a `path` argument to many -# of the methods. This `path` argument will determine which underlying -# repository is used. -# -# For a repository with submodules this would have the following outcome: -# -# ```coffee -# repo = atom.project.getRepositories()[0] -# repo.getShortHead() # 'master' -# repo.getShortHead('vendor/path/to/a/submodule') # 'dead1234' -# ``` -# -# ## Examples -# -# ### Logging the URL of the origin remote -# -# ```coffee -# git = atom.project.getRepositories()[0] -# console.log git.getOriginURL() -# ``` -# -# ### Requiring in packages -# -# ```coffee -# {GitRepository} = require 'atom' -# ``` -module.exports = -class GitRepository - @exists: (path) -> - if git = @open(path) - git.destroy() - true - else - false - - ### - Section: Construction and Destruction - ### - - # Public: Creates a new GitRepository instance. - # - # * `path` The {String} path to the Git repository to open. - # * `options` An optional {Object} with the following keys: - # * `refreshOnWindowFocus` A {Boolean}, `true` to refresh the index and - # statuses when the window is focused. - # - # Returns a {GitRepository} instance or `null` if the repository could not be opened. - @open: (path, options) -> - return null unless path - try - new GitRepository(path, options) - catch - null - - constructor: (path, options={}) -> - @emitter = new Emitter - @subscriptions = new CompositeDisposable - - @repo = GitUtils.open(path) - unless @repo? - throw new Error("No Git repository found searching path: #{path}") - - @statuses = {} - @upstream = {ahead: 0, behind: 0} - for submodulePath, submoduleRepo of @repo.submodules - submoduleRepo.upstream = {ahead: 0, behind: 0} - - {@project, @config, refreshOnWindowFocus} = options - - refreshOnWindowFocus ?= true - if refreshOnWindowFocus - onWindowFocus = => - @refreshIndex() - @refreshStatus() - - window.addEventListener 'focus', onWindowFocus - @subscriptions.add new Disposable(-> window.removeEventListener 'focus', onWindowFocus) - - if @project? - @project.getBuffers().forEach (buffer) => @subscribeToBuffer(buffer) - @subscriptions.add @project.onDidAddBuffer (buffer) => @subscribeToBuffer(buffer) - - # Public: Destroy this {GitRepository} object. - # - # This destroys any tasks and subscriptions and releases the underlying - # libgit2 repository handle. This method is idempotent. - destroy: -> - if @emitter? - @emitter.emit 'did-destroy' - @emitter.dispose() - @emitter = null - - if @statusTask? - @statusTask.terminate() - @statusTask = null - - if @repo? - @repo.release() - @repo = null - - if @subscriptions? - @subscriptions.dispose() - @subscriptions = null - - # Public: Returns a {Boolean} indicating if this repository has been destroyed. - isDestroyed: -> - not @repo? - - # Public: Invoke the given callback when this GitRepository's destroy() method - # is invoked. - # - # * `callback` {Function} - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidDestroy: (callback) -> - @emitter.once 'did-destroy', callback - - ### - Section: Event Subscription - ### - - # Public: Invoke the given callback when a specific file's status has - # changed. When a file is updated, reloaded, etc, and the status changes, this - # will be fired. - # - # * `callback` {Function} - # * `event` {Object} - # * `path` {String} the old parameters the decoration used to have - # * `pathStatus` {Number} representing the status. This value can be passed to - # {::isStatusModified} or {::isStatusNew} to get more information. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidChangeStatus: (callback) -> - @emitter.on 'did-change-status', callback - - # Public: Invoke the given callback when a multiple files' statuses have - # changed. For example, on window focus, the status of all the paths in the - # repo is checked. If any of them have changed, this will be fired. Call - # {::getPathStatus(path)} to get the status for your path of choice. - # - # * `callback` {Function} - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidChangeStatuses: (callback) -> - @emitter.on 'did-change-statuses', callback - - ### - Section: Repository Details - ### - - # Public: A {String} indicating the type of version control system used by - # this repository. - # - # Returns `"git"`. - getType: -> 'git' - - # Public: Returns the {String} path of the repository. - getPath: -> - @path ?= fs.absolute(@getRepo().getPath()) - - # Public: Returns the {String} working directory path of the repository. - getWorkingDirectory: -> @getRepo().getWorkingDirectory() - - # Public: Returns true if at the root, false if in a subfolder of the - # repository. - isProjectAtRoot: -> - @projectAtRoot ?= @project?.relativize(@getWorkingDirectory()) is '' - - # Public: Makes a path relative to the repository's working directory. - relativize: (path) -> @getRepo().relativize(path) - - # Public: Returns true if the given branch exists. - hasBranch: (branch) -> @getReferenceTarget("refs/heads/#{branch}")? - - # Public: Retrieves a shortened version of the HEAD reference value. - # - # This removes the leading segments of `refs/heads`, `refs/tags`, or - # `refs/remotes`. It also shortens the SHA-1 of a detached `HEAD` to 7 - # characters. - # - # * `path` An optional {String} path in the repository to get this information - # for, only needed if the repository contains submodules. - # - # Returns a {String}. - getShortHead: (path) -> @getRepo(path).getShortHead() - - # Public: Is the given path a submodule in the repository? - # - # * `path` The {String} path to check. - # - # Returns a {Boolean}. - isSubmodule: (path) -> - return false unless path - - repo = @getRepo(path) - if repo.isSubmodule(repo.relativize(path)) - true - else - # Check if the path is a working directory in a repo that isn't the root. - repo isnt @getRepo() and repo.relativize(join(path, 'dir')) is 'dir' - - # Public: Returns the number of commits behind the current branch is from the - # its upstream remote branch. - # - # * `reference` The {String} branch reference name. - # * `path` The {String} path in the repository to get this information for, - # only needed if the repository contains submodules. - getAheadBehindCount: (reference, path) -> - @getRepo(path).getAheadBehindCount(reference) - - # Public: Get the cached ahead/behind commit counts for the current branch's - # upstream branch. - # - # * `path` An optional {String} path in the repository to get this information - # for, only needed if the repository has submodules. - # - # Returns an {Object} with the following keys: - # * `ahead` The {Number} of commits ahead. - # * `behind` The {Number} of commits behind. - getCachedUpstreamAheadBehindCount: (path) -> - @getRepo(path).upstream ? @upstream - - # Public: Returns the git configuration value specified by the key. - # - # * `key` The {String} key for the configuration to lookup. - # * `path` An optional {String} path in the repository to get this information - # for, only needed if the repository has submodules. - getConfigValue: (key, path) -> @getRepo(path).getConfigValue(key) - - # Public: Returns the origin url of the repository. - # - # * `path` (optional) {String} path in the repository to get this information - # for, only needed if the repository has submodules. - getOriginURL: (path) -> @getConfigValue('remote.origin.url', path) - - # Public: Returns the upstream branch for the current HEAD, or null if there - # is no upstream branch for the current HEAD. - # - # * `path` An optional {String} path in the repo to get this information for, - # only needed if the repository contains submodules. - # - # Returns a {String} branch name such as `refs/remotes/origin/master`. - getUpstreamBranch: (path) -> @getRepo(path).getUpstreamBranch() - - # Public: Gets all the local and remote references. - # - # * `path` An optional {String} path in the repository to get this information - # for, only needed if the repository has submodules. - # - # Returns an {Object} with the following keys: - # * `heads` An {Array} of head reference names. - # * `remotes` An {Array} of remote reference names. - # * `tags` An {Array} of tag reference names. - getReferences: (path) -> @getRepo(path).getReferences() - - # Public: Returns the current {String} SHA for the given reference. - # - # * `reference` The {String} reference to get the target of. - # * `path` An optional {String} path in the repo to get the reference target - # for. Only needed if the repository contains submodules. - getReferenceTarget: (reference, path) -> - @getRepo(path).getReferenceTarget(reference) - - ### - Section: Reading Status - ### - - # Public: Returns true if the given path is modified. - # - # * `path` The {String} path to check. - # - # Returns a {Boolean} that's true if the `path` is modified. - isPathModified: (path) -> @isStatusModified(@getPathStatus(path)) - - # Public: Returns true if the given path is new. - # - # * `path` The {String} path to check. - # - # Returns a {Boolean} that's true if the `path` is new. - isPathNew: (path) -> @isStatusNew(@getPathStatus(path)) - - # Public: Is the given path ignored? - # - # * `path` The {String} path to check. - # - # Returns a {Boolean} that's true if the `path` is ignored. - isPathIgnored: (path) -> @getRepo().isIgnored(@relativize(path)) - - # Public: Get the status of a directory in the repository's working directory. - # - # * `path` The {String} path to check. - # - # Returns a {Number} representing the status. This value can be passed to - # {::isStatusModified} or {::isStatusNew} to get more information. - getDirectoryStatus: (directoryPath) -> - directoryPath = "#{@relativize(directoryPath)}/" - directoryStatus = 0 - for statusPath, status of @statuses - directoryStatus |= status if statusPath.indexOf(directoryPath) is 0 - directoryStatus - - # Public: Get the status of a single path in the repository. - # - # * `path` A {String} repository-relative path. - # - # Returns a {Number} representing the status. This value can be passed to - # {::isStatusModified} or {::isStatusNew} to get more information. - getPathStatus: (path) -> - repo = @getRepo(path) - relativePath = @relativize(path) - currentPathStatus = @statuses[relativePath] ? 0 - pathStatus = repo.getStatus(repo.relativize(path)) ? 0 - pathStatus = 0 if repo.isStatusIgnored(pathStatus) - if pathStatus > 0 - @statuses[relativePath] = pathStatus - else - delete @statuses[relativePath] - if currentPathStatus isnt pathStatus - @emitter.emit 'did-change-status', {path, pathStatus} - - pathStatus - - # Public: Get the cached status for the given path. - # - # * `path` A {String} path in the repository, relative or absolute. - # - # Returns a status {Number} or null if the path is not in the cache. - getCachedPathStatus: (path) -> - @statuses[@relativize(path)] - - # Public: Returns true if the given status indicates modification. - # - # * `status` A {Number} representing the status. - # - # Returns a {Boolean} that's true if the `status` indicates modification. - isStatusModified: (status) -> @getRepo().isStatusModified(status) - - # Public: Returns true if the given status indicates a new path. - # - # * `status` A {Number} representing the status. - # - # Returns a {Boolean} that's true if the `status` indicates a new path. - isStatusNew: (status) -> @getRepo().isStatusNew(status) - - ### - Section: Retrieving Diffs - ### - - # Public: Retrieves the number of lines added and removed to a path. - # - # This compares the working directory contents of the path to the `HEAD` - # version. - # - # * `path` The {String} path to check. - # - # Returns an {Object} with the following keys: - # * `added` The {Number} of added lines. - # * `deleted` The {Number} of deleted lines. - getDiffStats: (path) -> - repo = @getRepo(path) - repo.getDiffStats(repo.relativize(path)) - - # Public: Retrieves the line diffs comparing the `HEAD` version of the given - # path and the given text. - # - # * `path` The {String} path relative to the repository. - # * `text` The {String} to compare against the `HEAD` contents - # - # Returns an {Array} of hunk {Object}s with the following keys: - # * `oldStart` The line {Number} of the old hunk. - # * `newStart` The line {Number} of the new hunk. - # * `oldLines` The {Number} of lines in the old hunk. - # * `newLines` The {Number} of lines in the new hunk - getLineDiffs: (path, text) -> - # Ignore eol of line differences on windows so that files checked in as - # LF don't report every line modified when the text contains CRLF endings. - options = ignoreEolWhitespace: process.platform is 'win32' - repo = @getRepo(path) - repo.getLineDiffs(repo.relativize(path), text, options) - - ### - Section: Checking Out - ### - - # Public: Restore the contents of a path in the working directory and index - # to the version at `HEAD`. - # - # This is essentially the same as running: - # - # ```sh - # git reset HEAD -- - # git checkout HEAD -- - # ``` - # - # * `path` The {String} path to checkout. - # - # Returns a {Boolean} that's true if the method was successful. - checkoutHead: (path) -> - repo = @getRepo(path) - headCheckedOut = repo.checkoutHead(repo.relativize(path)) - @getPathStatus(path) if headCheckedOut - headCheckedOut - - # Public: Checks out a branch in your repository. - # - # * `reference` The {String} reference to checkout. - # * `create` A {Boolean} value which, if true creates the new reference if - # it doesn't exist. - # - # Returns a Boolean that's true if the method was successful. - checkoutReference: (reference, create) -> - @getRepo().checkoutReference(reference, create) - - ### - Section: Private - ### - - # Subscribes to buffer events. - subscribeToBuffer: (buffer) -> - getBufferPathStatus = => - if bufferPath = buffer.getPath() - @getPathStatus(bufferPath) - - getBufferPathStatus() - bufferSubscriptions = new CompositeDisposable - bufferSubscriptions.add buffer.onDidSave(getBufferPathStatus) - bufferSubscriptions.add buffer.onDidReload(getBufferPathStatus) - bufferSubscriptions.add buffer.onDidChangePath(getBufferPathStatus) - bufferSubscriptions.add buffer.onDidDestroy => - bufferSubscriptions.dispose() - @subscriptions.remove(bufferSubscriptions) - @subscriptions.add(bufferSubscriptions) - return - - # Subscribes to editor view event. - checkoutHeadForEditor: (editor) -> - buffer = editor.getBuffer() - if filePath = buffer.getPath() - @checkoutHead(filePath) - buffer.reload() - - # Returns the corresponding {Repository} - getRepo: (path) -> - if @repo? - @repo.submoduleForPath(path) ? @repo - else - throw new Error("Repository has been destroyed") - - # Reread the index to update any values that have changed since the - # last time the index was read. - refreshIndex: -> @getRepo().refreshIndex() - - # Refreshes the current git status in an outside process and asynchronously - # updates the relevant properties. - refreshStatus: -> - @handlerPath ?= require.resolve('./repository-status-handler') - - relativeProjectPaths = @project?.getPaths() - .map (projectPath) => @relativize(projectPath) - .filter (projectPath) -> projectPath.length > 0 and not path.isAbsolute(projectPath) - - @statusTask?.terminate() - new Promise (resolve) => - @statusTask = Task.once @handlerPath, @getPath(), relativeProjectPaths, ({statuses, upstream, branch, submodules}) => - statusesUnchanged = _.isEqual(statuses, @statuses) and - _.isEqual(upstream, @upstream) and - _.isEqual(branch, @branch) and - _.isEqual(submodules, @submodules) - - @statuses = statuses - @upstream = upstream - @branch = branch - @submodules = submodules - - for submodulePath, submoduleRepo of @getRepo().submodules - submoduleRepo.upstream = submodules[submodulePath]?.upstream ? {ahead: 0, behind: 0} - - unless statusesUnchanged - @emitter.emit 'did-change-statuses' - resolve() diff --git a/src/git-repository.js b/src/git-repository.js new file mode 100644 index 000000000..057c5fcb7 --- /dev/null +++ b/src/git-repository.js @@ -0,0 +1,603 @@ +/* + * decaffeinate suggestions: + * DS102: Remove unnecessary code created because of implicit returns + * DS104: Avoid inline assignments + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +const {join} = require('path') +const _ = require('underscore-plus') +const {Emitter, Disposable, CompositeDisposable} = require('event-kit') +const fs = require('fs-plus') +const path = require('path') +const GitUtils = require('git-utils') + +let nextId = 0 + +// Extended: Represents the underlying git operations performed by Atom. +// +// This class shouldn't be instantiated directly but instead by accessing the +// `atom.project` global and calling `getRepositories()`. Note that this will +// only be available when the project is backed by a Git repository. +// +// This class handles submodules automatically by taking a `path` argument to many +// of the methods. This `path` argument will determine which underlying +// repository is used. +// +// For a repository with submodules this would have the following outcome: +// +// ```coffee +// repo = atom.project.getRepositories()[0] +// repo.getShortHead() # 'master' +// repo.getShortHead('vendor/path/to/a/submodule') # 'dead1234' +// ``` +// +// ## Examples +// +// ### Logging the URL of the origin remote +// +// ```coffee +// git = atom.project.getRepositories()[0] +// console.log git.getOriginURL() +// ``` +// +// ### Requiring in packages +// +// ```coffee +// {GitRepository} = require 'atom' +// ``` +module.exports = +class GitRepository { + static exists (path) { + const git = this.open(path) + if (git) { + git.destroy() + return true + } else { + return false + } + } + + /* + Section: Construction and Destruction + */ + + // Public: Creates a new GitRepository instance. + // + // * `path` The {String} path to the Git repository to open. + // * `options` An optional {Object} with the following keys: + // * `refreshOnWindowFocus` A {Boolean}, `true` to refresh the index and + // statuses when the window is focused. + // + // Returns a {GitRepository} instance or `null` if the repository could not be opened. + static open (path, options) { + if (!path) { return null } + try { + return new GitRepository(path, options) + } catch (error) { + return null + } + } + + constructor (path, options = {}) { + this.id = nextId++ + this.emitter = new Emitter() + this.subscriptions = new CompositeDisposable() + this.repo = GitUtils.open(path) + if (this.repo == null) { + throw new Error(`No Git repository found searching path: ${path}`) + } + + this.statusRefreshCount = 0 + this.statuses = {} + this.upstream = {ahead: 0, behind: 0} + for (let submodulePath in this.repo.submodules) { + const submoduleRepo = this.repo.submodules[submodulePath] + submoduleRepo.upstream = {ahead: 0, behind: 0} + } + + this.project = options.project + this.config = options.config + + if (options.refreshOnWindowFocus || options.refreshOnWindowFocus == null) { + const onWindowFocus = () => { + this.refreshIndex() + this.refreshStatus() + } + + window.addEventListener('focus', onWindowFocus) + this.subscriptions.add(new Disposable(() => window.removeEventListener('focus', onWindowFocus))) + } + + if (this.project != null) { + this.project.getBuffers().forEach(buffer => this.subscribeToBuffer(buffer)) + this.subscriptions.add(this.project.onDidAddBuffer(buffer => this.subscribeToBuffer(buffer))) + } + } + + // Public: Destroy this {GitRepository} object. + // + // This destroys any tasks and subscriptions and releases the underlying + // libgit2 repository handle. This method is idempotent. + destroy () { + this.repo = null + + if (this.emitter) { + this.emitter.emit('did-destroy') + this.emitter.dispose() + this.emitter = null + } + + if (this.subscriptions) { + this.subscriptions.dispose() + this.subscriptions = null + } + } + + // Public: Returns a {Boolean} indicating if this repository has been destroyed. + isDestroyed () { + return this.repo == null + } + + // Public: Invoke the given callback when this GitRepository's destroy() method + // is invoked. + // + // * `callback` {Function} + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidDestroy (callback) { + return this.emitter.once('did-destroy', callback) + } + + /* + Section: Event Subscription + */ + + // Public: Invoke the given callback when a specific file's status has + // changed. When a file is updated, reloaded, etc, and the status changes, this + // will be fired. + // + // * `callback` {Function} + // * `event` {Object} + // * `path` {String} the old parameters the decoration used to have + // * `pathStatus` {Number} representing the status. This value can be passed to + // {::isStatusModified} or {::isStatusNew} to get more information. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidChangeStatus (callback) { + return this.emitter.on('did-change-status', callback) + } + + // Public: Invoke the given callback when a multiple files' statuses have + // changed. For example, on window focus, the status of all the paths in the + // repo is checked. If any of them have changed, this will be fired. Call + // {::getPathStatus(path)} to get the status for your path of choice. + // + // * `callback` {Function} + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidChangeStatuses (callback) { + return this.emitter.on('did-change-statuses', callback) + } + + /* + Section: Repository Details + */ + + // Public: A {String} indicating the type of version control system used by + // this repository. + // + // Returns `"git"`. + getType () { return 'git' } + + // Public: Returns the {String} path of the repository. + getPath () { + if (this.path == null) { + this.path = fs.absolute(this.getRepo().getPath()) + } + return this.path + } + + // Public: Returns the {String} working directory path of the repository. + getWorkingDirectory () { + return this.getRepo().getWorkingDirectory() + } + + // Public: Returns true if at the root, false if in a subfolder of the + // repository. + isProjectAtRoot () { + if (this.projectAtRoot == null) { + this.projectAtRoot = this.project && this.project.relativize(this.getWorkingDirectory()) === '' + } + return this.projectAtRoot + } + + // Public: Makes a path relative to the repository's working directory. + relativize (path) { + return this.getRepo().relativize(path) + } + + // Public: Returns true if the given branch exists. + hasBranch (branch) { + return this.getReferenceTarget(`refs/heads/${branch}`) != null + } + + // Public: Retrieves a shortened version of the HEAD reference value. + // + // This removes the leading segments of `refs/heads`, `refs/tags`, or + // `refs/remotes`. It also shortens the SHA-1 of a detached `HEAD` to 7 + // characters. + // + // * `path` An optional {String} path in the repository to get this information + // for, only needed if the repository contains submodules. + // + // Returns a {String}. + getShortHead (path) { + return this.getRepo(path).getShortHead() + } + + // Public: Is the given path a submodule in the repository? + // + // * `path` The {String} path to check. + // + // Returns a {Boolean}. + isSubmodule (path) { + if (!path) return false + + const repo = this.getRepo(path) + if (repo.isSubmodule(repo.relativize(path))) { + return true + } else { + // Check if the path is a working directory in a repo that isn't the root. + return repo !== this.getRepo() && repo.relativize(join(path, 'dir')) === 'dir' + } + } + + // Public: Returns the number of commits behind the current branch is from the + // its upstream remote branch. + // + // * `reference` The {String} branch reference name. + // * `path` The {String} path in the repository to get this information for, + // only needed if the repository contains submodules. + getAheadBehindCount (reference, path) { + return this.getRepo(path).getAheadBehindCount(reference) + } + + // Public: Get the cached ahead/behind commit counts for the current branch's + // upstream branch. + // + // * `path` An optional {String} path in the repository to get this information + // for, only needed if the repository has submodules. + // + // Returns an {Object} with the following keys: + // * `ahead` The {Number} of commits ahead. + // * `behind` The {Number} of commits behind. + getCachedUpstreamAheadBehindCount (path) { + return this.getRepo(path).upstream || this.upstream + } + + // Public: Returns the git configuration value specified by the key. + // + // * `key` The {String} key for the configuration to lookup. + // * `path` An optional {String} path in the repository to get this information + // for, only needed if the repository has submodules. + getConfigValue (key, path) { + return this.getRepo(path).getConfigValue(key) + } + + // Public: Returns the origin url of the repository. + // + // * `path` (optional) {String} path in the repository to get this information + // for, only needed if the repository has submodules. + getOriginURL (path) { + return this.getConfigValue('remote.origin.url', path) + } + + // Public: Returns the upstream branch for the current HEAD, or null if there + // is no upstream branch for the current HEAD. + // + // * `path` An optional {String} path in the repo to get this information for, + // only needed if the repository contains submodules. + // + // Returns a {String} branch name such as `refs/remotes/origin/master`. + getUpstreamBranch (path) { + return this.getRepo(path).getUpstreamBranch() + } + + // Public: Gets all the local and remote references. + // + // * `path` An optional {String} path in the repository to get this information + // for, only needed if the repository has submodules. + // + // Returns an {Object} with the following keys: + // * `heads` An {Array} of head reference names. + // * `remotes` An {Array} of remote reference names. + // * `tags` An {Array} of tag reference names. + getReferences (path) { + return this.getRepo(path).getReferences() + } + + // Public: Returns the current {String} SHA for the given reference. + // + // * `reference` The {String} reference to get the target of. + // * `path` An optional {String} path in the repo to get the reference target + // for. Only needed if the repository contains submodules. + getReferenceTarget (reference, path) { + return this.getRepo(path).getReferenceTarget(reference) + } + + /* + Section: Reading Status + */ + + // Public: Returns true if the given path is modified. + // + // * `path` The {String} path to check. + // + // Returns a {Boolean} that's true if the `path` is modified. + isPathModified (path) { + return this.isStatusModified(this.getPathStatus(path)) + } + + // Public: Returns true if the given path is new. + // + // * `path` The {String} path to check. + // + // Returns a {Boolean} that's true if the `path` is new. + isPathNew (path) { + return this.isStatusNew(this.getPathStatus(path)) + } + + // Public: Is the given path ignored? + // + // * `path` The {String} path to check. + // + // Returns a {Boolean} that's true if the `path` is ignored. + isPathIgnored (path) { + return this.getRepo().isIgnored(this.relativize(path)) + } + + // Public: Get the status of a directory in the repository's working directory. + // + // * `path` The {String} path to check. + // + // Returns a {Number} representing the status. This value can be passed to + // {::isStatusModified} or {::isStatusNew} to get more information. + getDirectoryStatus (directoryPath) { + directoryPath = `${this.relativize(directoryPath)}/` + let directoryStatus = 0 + for (let statusPath in this.statuses) { + const status = this.statuses[statusPath] + if (statusPath.startsWith(directoryPath)) directoryStatus |= status + } + return directoryStatus + } + + // Public: Get the status of a single path in the repository. + // + // * `path` A {String} repository-relative path. + // + // Returns a {Number} representing the status. This value can be passed to + // {::isStatusModified} or {::isStatusNew} to get more information. + getPathStatus (path) { + const repo = this.getRepo(path) + const relativePath = this.relativize(path) + const currentPathStatus = this.statuses[relativePath] || 0 + let pathStatus = repo.getStatus(repo.relativize(path)) || 0 + if (repo.isStatusIgnored(pathStatus)) pathStatus = 0 + if (pathStatus > 0) { + this.statuses[relativePath] = pathStatus + } else { + delete this.statuses[relativePath] + } + if (currentPathStatus !== pathStatus) { + this.emitter.emit('did-change-status', {path, pathStatus}) + } + + return pathStatus + } + + // Public: Get the cached status for the given path. + // + // * `path` A {String} path in the repository, relative or absolute. + // + // Returns a status {Number} or null if the path is not in the cache. + getCachedPathStatus (path) { + return this.statuses[this.relativize(path)] + } + + // Public: Returns true if the given status indicates modification. + // + // * `status` A {Number} representing the status. + // + // Returns a {Boolean} that's true if the `status` indicates modification. + isStatusModified (status) { return this.getRepo().isStatusModified(status) } + + // Public: Returns true if the given status indicates a new path. + // + // * `status` A {Number} representing the status. + // + // Returns a {Boolean} that's true if the `status` indicates a new path. + isStatusNew (status) { + return this.getRepo().isStatusNew(status) + } + + /* + Section: Retrieving Diffs + */ + + // Public: Retrieves the number of lines added and removed to a path. + // + // This compares the working directory contents of the path to the `HEAD` + // version. + // + // * `path` The {String} path to check. + // + // Returns an {Object} with the following keys: + // * `added` The {Number} of added lines. + // * `deleted` The {Number} of deleted lines. + getDiffStats (path) { + const repo = this.getRepo(path) + return repo.getDiffStats(repo.relativize(path)) + } + + // Public: Retrieves the line diffs comparing the `HEAD` version of the given + // path and the given text. + // + // * `path` The {String} path relative to the repository. + // * `text` The {String} to compare against the `HEAD` contents + // + // Returns an {Array} of hunk {Object}s with the following keys: + // * `oldStart` The line {Number} of the old hunk. + // * `newStart` The line {Number} of the new hunk. + // * `oldLines` The {Number} of lines in the old hunk. + // * `newLines` The {Number} of lines in the new hunk + getLineDiffs (path, text) { + // Ignore eol of line differences on windows so that files checked in as + // LF don't report every line modified when the text contains CRLF endings. + const options = {ignoreEolWhitespace: process.platform === 'win32'} + const repo = this.getRepo(path) + return repo.getLineDiffs(repo.relativize(path), text, options) + } + + /* + Section: Checking Out + */ + + // Public: Restore the contents of a path in the working directory and index + // to the version at `HEAD`. + // + // This is essentially the same as running: + // + // ```sh + // git reset HEAD -- + // git checkout HEAD -- + // ``` + // + // * `path` The {String} path to checkout. + // + // Returns a {Boolean} that's true if the method was successful. + checkoutHead (path) { + const repo = this.getRepo(path) + const headCheckedOut = repo.checkoutHead(repo.relativize(path)) + if (headCheckedOut) this.getPathStatus(path) + return headCheckedOut + } + + // Public: Checks out a branch in your repository. + // + // * `reference` The {String} reference to checkout. + // * `create` A {Boolean} value which, if true creates the new reference if + // it doesn't exist. + // + // Returns a Boolean that's true if the method was successful. + checkoutReference (reference, create) { + return this.getRepo().checkoutReference(reference, create) + } + + /* + Section: Private + */ + + // Subscribes to buffer events. + subscribeToBuffer (buffer) { + const getBufferPathStatus = () => { + const bufferPath = buffer.getPath() + if (bufferPath) this.getPathStatus(bufferPath) + } + + getBufferPathStatus() + const bufferSubscriptions = new CompositeDisposable() + bufferSubscriptions.add(buffer.onDidSave(getBufferPathStatus)) + bufferSubscriptions.add(buffer.onDidReload(getBufferPathStatus)) + bufferSubscriptions.add(buffer.onDidChangePath(getBufferPathStatus)) + bufferSubscriptions.add(buffer.onDidDestroy(() => { + bufferSubscriptions.dispose() + return this.subscriptions.remove(bufferSubscriptions) + })) + this.subscriptions.add(bufferSubscriptions) + } + + // Subscribes to editor view event. + checkoutHeadForEditor (editor) { + const buffer = editor.getBuffer() + const bufferPath = buffer.getPath() + if (bufferPath) { + this.checkoutHead(bufferPath) + return buffer.reload() + } + } + + // Returns the corresponding {Repository} + getRepo (path) { + if (this.repo) { + return this.repo.submoduleForPath(path) || this.repo + } else { + throw new Error('Repository has been destroyed') + } + } + + // Reread the index to update any values that have changed since the + // last time the index was read. + refreshIndex () { + return this.getRepo().refreshIndex() + } + + // Refreshes the current git status in an outside process and asynchronously + // updates the relevant properties. + async refreshStatus () { + const statusRefreshCount = ++this.statusRefreshCount + const repo = this.getRepo() + + const relativeProjectPaths = this.project && this.project.getPaths() + .map(projectPath => this.relativize(projectPath)) + .filter(projectPath => (projectPath.length > 0) && !path.isAbsolute(projectPath)) + + const branch = await repo.getHeadAsync() + const upstream = await repo.getAheadBehindCountAsync() + + const statuses = {} + const repoStatus = relativeProjectPaths.length > 0 + ? await repo.getStatusAsync(relativeProjectPaths) + : await repo.getStatusAsync() + for (let filePath in repoStatus) { + statuses[filePath] = repoStatus[filePath] + } + + const submodules = {} + for (let submodulePath in repo.submodules) { + const submoduleRepo = repo.submodules[submodulePath] + submodules[submodulePath] = { + branch: await submoduleRepo.getHeadAsync(), + upstream: await submoduleRepo.getAheadBehindCountAsync() + } + + const workingDirectoryPath = submoduleRepo.getWorkingDirectory() + const submoduleStatus = await submoduleRepo.getStatusAsync() + for (let filePath in submoduleStatus) { + const absolutePath = path.join(workingDirectoryPath, filePath) + const relativizePath = repo.relativize(absolutePath) + statuses[relativizePath] = submoduleStatus[filePath] + } + } + + if (this.statusRefreshCount !== statusRefreshCount || this.isDestroyed()) return + + const statusesUnchanged = + _.isEqual(branch, this.branch) && + _.isEqual(statuses, this.statuses) && + _.isEqual(upstream, this.upstream) && + _.isEqual(submodules, this.submodules) + + this.branch = branch + this.statuses = statuses + this.upstream = upstream + this.submodules = submodules + + for (let submodulePath in repo.submodules) { + repo.submodules[submodulePath].upstream = submodules[submodulePath].upstream + } + + if (!statusesUnchanged) this.emitter.emit('did-change-statuses') + } +} diff --git a/src/language-mode.coffee b/src/language-mode.coffee deleted file mode 100644 index 1839f1c59..000000000 --- a/src/language-mode.coffee +++ /dev/null @@ -1,350 +0,0 @@ -{Range} = require 'text-buffer' -_ = require 'underscore-plus' -{OnigRegExp} = require 'oniguruma' -ScopeDescriptor = require './scope-descriptor' -NullGrammar = require './null-grammar' - -module.exports = -class LanguageMode - # Sets up a `LanguageMode` for the given {TextEditor}. - # - # editor - The {TextEditor} to associate with - constructor: (@editor) -> - {@buffer} = @editor - @regexesByPattern = {} - - destroy: -> - - toggleLineCommentForBufferRow: (row) -> - @toggleLineCommentsForBufferRows(row, row) - - # Wraps the lines between two rows in comments. - # - # If the language doesn't have comment, nothing happens. - # - # startRow - The row {Number} to start at - # endRow - The row {Number} to end at - toggleLineCommentsForBufferRows: (start, end) -> - scope = @editor.scopeDescriptorForBufferPosition([start, 0]) - commentStrings = @editor.getCommentStrings(scope) - return unless commentStrings?.commentStartString - {commentStartString, commentEndString} = commentStrings - - buffer = @editor.buffer - commentStartRegexString = _.escapeRegExp(commentStartString).replace(/(\s+)$/, '(?:$1)?') - commentStartRegex = new OnigRegExp("^(\\s*)(#{commentStartRegexString})") - - if commentEndString - shouldUncomment = commentStartRegex.testSync(buffer.lineForRow(start)) - if shouldUncomment - commentEndRegexString = _.escapeRegExp(commentEndString).replace(/^(\s+)/, '(?:$1)?') - commentEndRegex = new OnigRegExp("(#{commentEndRegexString})(\\s*)$") - startMatch = commentStartRegex.searchSync(buffer.lineForRow(start)) - endMatch = commentEndRegex.searchSync(buffer.lineForRow(end)) - if startMatch and endMatch - buffer.transact -> - columnStart = startMatch[1].length - columnEnd = columnStart + startMatch[2].length - buffer.setTextInRange([[start, columnStart], [start, columnEnd]], "") - - endLength = buffer.lineLengthForRow(end) - endMatch[2].length - endColumn = endLength - endMatch[1].length - buffer.setTextInRange([[end, endColumn], [end, endLength]], "") - else - buffer.transact -> - indentLength = buffer.lineForRow(start).match(/^\s*/)?[0].length ? 0 - buffer.insert([start, indentLength], commentStartString) - buffer.insert([end, buffer.lineLengthForRow(end)], commentEndString) - else - allBlank = true - allBlankOrCommented = true - - for row in [start..end] by 1 - line = buffer.lineForRow(row) - blank = line?.match(/^\s*$/) - - allBlank = false unless blank - allBlankOrCommented = false unless blank or commentStartRegex.testSync(line) - - shouldUncomment = allBlankOrCommented and not allBlank - - if shouldUncomment - for row in [start..end] by 1 - if match = commentStartRegex.searchSync(buffer.lineForRow(row)) - columnStart = match[1].length - columnEnd = columnStart + match[2].length - buffer.setTextInRange([[row, columnStart], [row, columnEnd]], "") - else - if start is end - indent = @editor.indentationForBufferRow(start) - else - indent = @minIndentLevelForRowRange(start, end) - indentString = @editor.buildIndentString(indent) - tabLength = @editor.getTabLength() - indentRegex = new RegExp("(\t|[ ]{#{tabLength}}){#{Math.floor(indent)}}") - for row in [start..end] by 1 - line = buffer.lineForRow(row) - if indentLength = line.match(indentRegex)?[0].length - buffer.insert([row, indentLength], commentStartString) - else - buffer.setTextInRange([[row, 0], [row, indentString.length]], indentString + commentStartString) - return - - # Folds all the foldable lines in the buffer. - foldAll: -> - @unfoldAll() - foldedRowRanges = {} - for currentRow in [0..@buffer.getLastRow()] by 1 - rowRange = [startRow, endRow] = @rowRangeForFoldAtBufferRow(currentRow) ? [] - continue unless startRow? - continue if foldedRowRanges[rowRange] - - @editor.foldBufferRowRange(startRow, endRow) - foldedRowRanges[rowRange] = true - return - - # Unfolds all the foldable lines in the buffer. - unfoldAll: -> - @editor.displayLayer.destroyAllFolds() - - # Fold all comment and code blocks at a given indentLevel - # - # indentLevel - A {Number} indicating indentLevel; 0 based. - foldAllAtIndentLevel: (indentLevel) -> - @unfoldAll() - foldedRowRanges = {} - for currentRow in [0..@buffer.getLastRow()] by 1 - rowRange = [startRow, endRow] = @rowRangeForFoldAtBufferRow(currentRow) ? [] - continue unless startRow? - continue if foldedRowRanges[rowRange] - - # assumption: startRow will always be the min indent level for the entire range - if @editor.indentationForBufferRow(startRow) is indentLevel - @editor.foldBufferRowRange(startRow, endRow) - foldedRowRanges[rowRange] = true - return - - # Given a buffer row, creates a fold at it. - # - # bufferRow - A {Number} indicating the buffer row - # - # Returns the new {Fold}. - foldBufferRow: (bufferRow) -> - for currentRow in [bufferRow..0] by -1 - [startRow, endRow] = @rowRangeForFoldAtBufferRow(currentRow) ? [] - continue unless startRow? and startRow <= bufferRow <= endRow - unless @editor.isFoldedAtBufferRow(startRow) - return @editor.foldBufferRowRange(startRow, endRow) - - # Find the row range for a fold at a given bufferRow. Will handle comments - # and code. - # - # bufferRow - A {Number} indicating the buffer row - # - # Returns an {Array} of the [startRow, endRow]. Returns null if no range. - rowRangeForFoldAtBufferRow: (bufferRow) -> - rowRange = @rowRangeForCommentAtBufferRow(bufferRow) - rowRange ?= @rowRangeForCodeFoldAtBufferRow(bufferRow) - rowRange - - rowRangeForCommentAtBufferRow: (bufferRow) -> - return unless @editor.tokenizedBuffer.tokenizedLines[bufferRow]?.isComment() - - startRow = bufferRow - endRow = bufferRow - - if bufferRow > 0 - for currentRow in [bufferRow-1..0] by -1 - break unless @editor.tokenizedBuffer.tokenizedLines[currentRow]?.isComment() - startRow = currentRow - - if bufferRow < @buffer.getLastRow() - for currentRow in [bufferRow+1..@buffer.getLastRow()] by 1 - break unless @editor.tokenizedBuffer.tokenizedLines[currentRow]?.isComment() - endRow = currentRow - - return [startRow, endRow] if startRow isnt endRow - - rowRangeForCodeFoldAtBufferRow: (bufferRow) -> - return null unless @isFoldableAtBufferRow(bufferRow) - - startIndentLevel = @editor.indentationForBufferRow(bufferRow) - scopeDescriptor = @editor.scopeDescriptorForBufferPosition([bufferRow, 0]) - for row in [(bufferRow + 1)..@editor.getLastBufferRow()] by 1 - continue if @editor.isBufferRowBlank(row) - indentation = @editor.indentationForBufferRow(row) - if indentation <= startIndentLevel - includeRowInFold = indentation is startIndentLevel and @foldEndRegexForScopeDescriptor(scopeDescriptor)?.searchSync(@editor.lineTextForBufferRow(row)) - foldEndRow = row if includeRowInFold - break - - foldEndRow = row - - [bufferRow, foldEndRow] - - isFoldableAtBufferRow: (bufferRow) -> - @editor.tokenizedBuffer.isFoldableAtRow(bufferRow) - - # Returns a {Boolean} indicating whether the line at the given buffer - # row is a comment. - isLineCommentedAtBufferRow: (bufferRow) -> - return false unless 0 <= bufferRow <= @editor.getLastBufferRow() - @editor.tokenizedBuffer.tokenizedLines[bufferRow]?.isComment() ? false - - # Find a row range for a 'paragraph' around specified bufferRow. A paragraph - # is a block of text bounded by and empty line or a block of text that is not - # the same type (comments next to source code). - rowRangeForParagraphAtBufferRow: (bufferRow) -> - scope = @editor.scopeDescriptorForBufferPosition([bufferRow, 0]) - commentStrings = @editor.getCommentStrings(scope) - commentStartRegex = null - if commentStrings?.commentStartString? and not commentStrings.commentEndString? - commentStartRegexString = _.escapeRegExp(commentStrings.commentStartString).replace(/(\s+)$/, '(?:$1)?') - commentStartRegex = new OnigRegExp("^(\\s*)(#{commentStartRegexString})") - - filterCommentStart = (line) -> - if commentStartRegex? - matches = commentStartRegex.searchSync(line) - line = line.substring(matches[0].end) if matches?.length - line - - return unless /\S/.test(filterCommentStart(@editor.lineTextForBufferRow(bufferRow))) - - if @isLineCommentedAtBufferRow(bufferRow) - isOriginalRowComment = true - range = @rowRangeForCommentAtBufferRow(bufferRow) - [firstRow, lastRow] = range or [bufferRow, bufferRow] - else - isOriginalRowComment = false - [firstRow, lastRow] = [0, @editor.getLastBufferRow()-1] - - startRow = bufferRow - while startRow > firstRow - break if @isLineCommentedAtBufferRow(startRow - 1) isnt isOriginalRowComment - break unless /\S/.test(filterCommentStart(@editor.lineTextForBufferRow(startRow - 1))) - startRow-- - - endRow = bufferRow - lastRow = @editor.getLastBufferRow() - while endRow < lastRow - break if @isLineCommentedAtBufferRow(endRow + 1) isnt isOriginalRowComment - break unless /\S/.test(filterCommentStart(@editor.lineTextForBufferRow(endRow + 1))) - endRow++ - - new Range([startRow, 0], [endRow, @editor.lineTextForBufferRow(endRow).length]) - - # Given a buffer row, this returns a suggested indentation level. - # - # The indentation level provided is based on the current {LanguageMode}. - # - # bufferRow - A {Number} indicating the buffer row - # - # Returns a {Number}. - suggestedIndentForBufferRow: (bufferRow, options) -> - line = @buffer.lineForRow(bufferRow) - tokenizedLine = @editor.tokenizedBuffer.tokenizedLineForRow(bufferRow) - @suggestedIndentForTokenizedLineAtBufferRow(bufferRow, line, tokenizedLine, options) - - suggestedIndentForLineAtBufferRow: (bufferRow, line, options) -> - tokenizedLine = @editor.tokenizedBuffer.buildTokenizedLineForRowWithText(bufferRow, line) - @suggestedIndentForTokenizedLineAtBufferRow(bufferRow, line, tokenizedLine, options) - - suggestedIndentForTokenizedLineAtBufferRow: (bufferRow, line, tokenizedLine, options) -> - iterator = tokenizedLine.getTokenIterator() - iterator.next() - scopeDescriptor = new ScopeDescriptor(scopes: iterator.getScopes()) - - increaseIndentRegex = @increaseIndentRegexForScopeDescriptor(scopeDescriptor) - decreaseIndentRegex = @decreaseIndentRegexForScopeDescriptor(scopeDescriptor) - decreaseNextIndentRegex = @decreaseNextIndentRegexForScopeDescriptor(scopeDescriptor) - - if options?.skipBlankLines ? true - precedingRow = @buffer.previousNonBlankRow(bufferRow) - return 0 unless precedingRow? - else - precedingRow = bufferRow - 1 - return 0 if precedingRow < 0 - - desiredIndentLevel = @editor.indentationForBufferRow(precedingRow) - return desiredIndentLevel unless increaseIndentRegex - - unless @editor.isBufferRowCommented(precedingRow) - precedingLine = @buffer.lineForRow(precedingRow) - desiredIndentLevel += 1 if increaseIndentRegex?.testSync(precedingLine) - desiredIndentLevel -= 1 if decreaseNextIndentRegex?.testSync(precedingLine) - - unless @buffer.isRowBlank(precedingRow) - desiredIndentLevel -= 1 if decreaseIndentRegex?.testSync(line) - - Math.max(desiredIndentLevel, 0) - - # Calculate a minimum indent level for a range of lines excluding empty lines. - # - # startRow - The row {Number} to start at - # endRow - The row {Number} to end at - # - # Returns a {Number} of the indent level of the block of lines. - minIndentLevelForRowRange: (startRow, endRow) -> - indents = (@editor.indentationForBufferRow(row) for row in [startRow..endRow] by 1 when not @editor.isBufferRowBlank(row)) - indents = [0] unless indents.length - Math.min(indents...) - - # Indents all the rows between two buffer row numbers. - # - # startRow - The row {Number} to start at - # endRow - The row {Number} to end at - autoIndentBufferRows: (startRow, endRow) -> - @autoIndentBufferRow(row) for row in [startRow..endRow] by 1 - return - - # Given a buffer row, this indents it. - # - # bufferRow - The row {Number}. - # options - An options {Object} to pass through to {TextEditor::setIndentationForBufferRow}. - autoIndentBufferRow: (bufferRow, options) -> - indentLevel = @suggestedIndentForBufferRow(bufferRow, options) - @editor.setIndentationForBufferRow(bufferRow, indentLevel, options) - - # Given a buffer row, this decreases the indentation. - # - # bufferRow - The row {Number} - autoDecreaseIndentForBufferRow: (bufferRow) -> - scopeDescriptor = @editor.scopeDescriptorForBufferPosition([bufferRow, 0]) - return unless decreaseIndentRegex = @decreaseIndentRegexForScopeDescriptor(scopeDescriptor) - - line = @buffer.lineForRow(bufferRow) - return unless decreaseIndentRegex.testSync(line) - - currentIndentLevel = @editor.indentationForBufferRow(bufferRow) - return if currentIndentLevel is 0 - - precedingRow = @buffer.previousNonBlankRow(bufferRow) - return unless precedingRow? - - precedingLine = @buffer.lineForRow(precedingRow) - desiredIndentLevel = @editor.indentationForBufferRow(precedingRow) - - if increaseIndentRegex = @increaseIndentRegexForScopeDescriptor(scopeDescriptor) - desiredIndentLevel -= 1 unless increaseIndentRegex.testSync(precedingLine) - - if decreaseNextIndentRegex = @decreaseNextIndentRegexForScopeDescriptor(scopeDescriptor) - desiredIndentLevel -= 1 if decreaseNextIndentRegex.testSync(precedingLine) - - if desiredIndentLevel >= 0 and desiredIndentLevel < currentIndentLevel - @editor.setIndentationForBufferRow(bufferRow, desiredIndentLevel) - - cacheRegex: (pattern) -> - if pattern - @regexesByPattern[pattern] ?= new OnigRegExp(pattern) - - increaseIndentRegexForScopeDescriptor: (scopeDescriptor) -> - @cacheRegex(@editor.getIncreaseIndentPattern(scopeDescriptor)) - - decreaseIndentRegexForScopeDescriptor: (scopeDescriptor) -> - @cacheRegex(@editor.getDecreaseIndentPattern(scopeDescriptor)) - - decreaseNextIndentRegexForScopeDescriptor: (scopeDescriptor) -> - @cacheRegex(@editor.getDecreaseNextIndentPattern(scopeDescriptor)) - - foldEndRegexForScopeDescriptor: (scopeDescriptor) -> - @cacheRegex(@editor.getFoldEndPattern(scopeDescriptor)) diff --git a/src/package.coffee b/src/package.coffee index d5e13b6b9..42647acb5 100644 --- a/src/package.coffee +++ b/src/package.coffee @@ -534,7 +534,7 @@ class Package console.error "Error deactivating package '#{@name}'", e.stack # We support then-able async promises as well as sync ones from deactivate - if deactivationResult?.then is 'function' + if typeof deactivationResult?.then is 'function' deactivationResult.then => @afterDeactivation() else @afterDeactivation() diff --git a/src/pane-element.coffee b/src/pane-element.coffee index c4866816a..d68b3b834 100644 --- a/src/pane-element.coffee +++ b/src/pane-element.coffee @@ -79,6 +79,7 @@ class PaneElement extends HTMLElement activeItemChanged: (item) -> delete @dataset.activeItemName delete @dataset.activeItemPath + @changePathDisposable?.dispose() return unless item? @@ -89,6 +90,12 @@ class PaneElement extends HTMLElement @dataset.activeItemName = path.basename(itemPath) @dataset.activeItemPath = itemPath + if item.onDidChangePath? + @changePathDisposable = item.onDidChangePath => + itemPath = item.getPath() + @dataset.activeItemName = path.basename(itemPath) + @dataset.activeItemPath = itemPath + unless @itemViews.contains(itemView) @itemViews.appendChild(itemView) @@ -119,6 +126,7 @@ class PaneElement extends HTMLElement paneDestroyed: -> @subscriptions.dispose() + @changePathDisposable?.dispose() flexScaleChanged: (flexScale) -> @style.flexGrow = flexScale diff --git a/src/repository-status-handler.coffee b/src/repository-status-handler.coffee deleted file mode 100644 index 2fda9a335..000000000 --- a/src/repository-status-handler.coffee +++ /dev/null @@ -1,36 +0,0 @@ -Git = require 'git-utils' -path = require 'path' - -module.exports = (repoPath, paths = []) -> - repo = Git.open(repoPath) - - upstream = {} - statuses = {} - submodules = {} - branch = null - - if repo? - # Statuses in main repo - workingDirectoryPath = repo.getWorkingDirectory() - repoStatus = (if paths.length > 0 then repo.getStatusForPaths(paths) else repo.getStatus()) - for filePath, status of repoStatus - statuses[filePath] = status - - # Statuses in submodules - for submodulePath, submoduleRepo of repo.submodules - submodules[submodulePath] = - branch: submoduleRepo.getHead() - upstream: submoduleRepo.getAheadBehindCount() - - workingDirectoryPath = submoduleRepo.getWorkingDirectory() - for filePath, status of submoduleRepo.getStatus() - absolutePath = path.join(workingDirectoryPath, filePath) - # Make path relative to parent repository - relativePath = repo.relativize(absolutePath) - statuses[relativePath] = status - - upstream = repo.getAheadBehindCount() - branch = repo.getHead() - repo.release() - - {statuses, upstream, branch, submodules} diff --git a/src/selection.coffee b/src/selection.coffee index e361d0b5c..4d3fe8882 100644 --- a/src/selection.coffee +++ b/src/selection.coffee @@ -381,7 +381,7 @@ class Selection extends Model if options.autoIndent and textIsAutoIndentable and not NonWhitespaceRegExp.test(precedingText) and remainingLines.length > 0 autoIndentFirstLine = true firstLine = precedingText + firstInsertedLine - desiredIndentLevel = @editor.languageMode.suggestedIndentForLineAtBufferRow(oldBufferRange.start.row, firstLine) + desiredIndentLevel = @editor.tokenizedBuffer.suggestedIndentForLineAtBufferRow(oldBufferRange.start.row, firstLine) indentAdjustment = desiredIndentLevel - @editor.indentLevelForLine(firstLine) @adjustIndent(remainingLines, indentAdjustment) diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 093f2590e..3060b6857 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -362,7 +362,7 @@ class TextEditorComponent { this.requestHorizontalMeasurement(screenRange.start.row, screenRange.start.column) this.requestHorizontalMeasurement(screenRange.end.row, screenRange.end.column) } - this.populateVisibleRowRange() + this.populateVisibleRowRange(this.getRenderedStartRow()) this.populateVisibleTiles() this.queryScreenLinesToRender() this.queryLongestLine() @@ -1883,7 +1883,7 @@ class TextEditorComponent { function didMouseUp () { window.removeEventListener('mousemove', didMouseMove) - window.removeEventListener('mouseup', didMouseUp) + window.removeEventListener('mouseup', didMouseUp, {capture: true}) bufferWillChangeDisposable.dispose() if (dragging) { dragging = false @@ -2096,14 +2096,29 @@ class TextEditorComponent { return marginInBaseCharacters * this.getBaseCharacterWidth() } + // This method is called at the beginning of a frame render to relay any + // potential changes in the editor's width into the model before proceeding. updateModelSoftWrapColumn () { const {model} = this.props const newEditorWidthInChars = this.getScrollContainerClientWidthInBaseCharacters() if (newEditorWidthInChars !== model.getEditorWidthInChars()) { this.suppressUpdates = true + + const renderedStartRow = this.getRenderedStartRow() this.props.model.setEditorWidthInChars(newEditorWidthInChars) - // Wrapping may cause a vertical scrollbar to appear, which will change the width again. + + // Relaying a change in to the editor's client width may cause the + // vertical scrollbar to appear or disappear, which causes the editor's + // client width to change *again*. Make sure the display layer is fully + // populated for the visible area before recalculating the editor's + // width in characters. Then update the display layer *again* just in + // case a change in scrollbar visibility causes lines to wrap + // differently. We capture the renderedStartRow before resetting the + // display layer because once it has been reset, we can't compute the + // rendered start row accurately. 😥 + this.populateVisibleRowRange(renderedStartRow) this.props.model.setEditorWidthInChars(this.getScrollContainerClientWidthInBaseCharacters()) + this.suppressUpdates = false } } @@ -2867,12 +2882,11 @@ class TextEditorComponent { } } - // Ensure the spatial index is populated with rows that are currently - // visible so we *at least* get the longest row in the visible range. - populateVisibleRowRange () { + // Ensure the spatial index is populated with rows that are currently visible + populateVisibleRowRange (renderedStartRow) { const editorHeightInTiles = this.getScrollContainerHeight() / this.getLineHeight() const visibleTileCount = Math.ceil(editorHeightInTiles) + 1 - const lastRenderedRow = this.getRenderedStartRow() + (visibleTileCount * this.getRowsPerTile()) + const lastRenderedRow = renderedStartRow + (visibleTileCount * this.getRowsPerTile()) this.props.model.displayLayer.populateSpatialIndexIfNeeded(Infinity, lastRenderedRow) } diff --git a/src/text-editor-registry.js b/src/text-editor-registry.js index 35be27fd1..2cbf3093c 100644 --- a/src/text-editor-registry.js +++ b/src/text-editor-registry.js @@ -429,3 +429,5 @@ class ScopedSettingsDelegate { } } } + +TextEditorRegistry.ScopedSettingsDelegate = ScopedSettingsDelegate diff --git a/src/text-editor.coffee b/src/text-editor.coffee index a84f6f631..c00508f09 100644 --- a/src/text-editor.coffee +++ b/src/text-editor.coffee @@ -4,7 +4,6 @@ fs = require 'fs-plus' Grim = require 'grim' {CompositeDisposable, Disposable, Emitter} = require 'event-kit' {Point, Range} = TextBuffer = require 'text-buffer' -LanguageMode = require './language-mode' DecorationManager = require './decoration-manager' TokenizedBuffer = require './tokenized-buffer' Cursor = require './cursor' @@ -16,6 +15,7 @@ TextEditorComponent = null TextEditorElement = null {isDoubleWidthCharacter, isHalfWidthCharacter, isKoreanCharacter, isWrapBoundary} = require './text-utils' +NON_WHITESPACE_REGEXP = /\S/ ZERO_WIDTH_NBSP = '\ufeff' # Essential: This class represents all essential editing state for a single @@ -78,7 +78,6 @@ class TextEditor extends Model serializationVersion: 1 buffer: null - languageMode: null cursors: null showCursorOnSelection: null selections: null @@ -122,6 +121,8 @@ class TextEditor extends Model this ) + Object.defineProperty(@prototype, 'languageMode', get: -> @tokenizedBuffer) + @deserialize: (state, atomEnvironment) -> # TODO: Return null on version mismatch when 1.8.0 has been out for a while if state.version isnt @prototype.serializationVersion and state.displayBuffer? @@ -243,8 +244,6 @@ class TextEditor extends Model initialColumn = Math.max(parseInt(initialColumn) or 0, 0) @addCursorAtBufferPosition([initialLine, initialColumn]) - @languageMode = new LanguageMode(this) - @gutterContainer = new GutterContainer(this) @lineNumberGutter = @gutterContainer.addGutter name: 'line-number' @@ -482,7 +481,6 @@ class TextEditor extends Model @tokenizedBuffer.destroy() selection.destroy() for selection in @selections.slice() @buffer.release() - @languageMode.destroy() @gutterContainer.destroy() @emitter.emit 'did-destroy' @emitter.clear() @@ -963,7 +961,7 @@ class TextEditor extends Model # this editor. shouldPromptToSave: ({windowCloseRequested, projectHasPaths}={}) -> if windowCloseRequested and projectHasPaths and atom.stateStore.isConnected() - false + @buffer.isInConflict() else @isModified() and not @buffer.hasMultipleEditors() @@ -2210,7 +2208,7 @@ class TextEditor extends Model # # Returns a {Cursor}. addCursorAtBufferPosition: (bufferPosition, options) -> - @selectionsMarkerLayer.markBufferPosition(bufferPosition, Object.assign({invalidate: 'never'}, options)) + @selectionsMarkerLayer.markBufferPosition(bufferPosition, {invalidate: 'never'}) @getLastSelection().cursor.autoscroll() unless options?.autoscroll is false @getLastSelection().cursor @@ -3311,13 +3309,15 @@ class TextEditor extends Model # indentation level up to the nearest following row with a lower indentation # level. foldCurrentRow: -> - bufferRow = @bufferPositionForScreenPosition(@getCursorScreenPosition()).row - @foldBufferRow(bufferRow) + {row} = @getCursorBufferPosition() + range = @tokenizedBuffer.getFoldableRangeContainingPoint(Point(row, Infinity)) + @displayLayer.foldBufferRange(range) # Essential: Unfold the most recent cursor's row by one level. unfoldCurrentRow: -> - bufferRow = @bufferPositionForScreenPosition(@getCursorScreenPosition()).row - @unfoldBufferRow(bufferRow) + {row} = @getCursorBufferPosition() + position = Point(row, Infinity) + @displayLayer.destroyFoldsIntersectingBufferRange(Range(position, position)) # Essential: Fold the given row in buffer coordinates based on its indentation # level. @@ -3327,13 +3327,26 @@ class TextEditor extends Model # # * `bufferRow` A {Number}. foldBufferRow: (bufferRow) -> - @languageMode.foldBufferRow(bufferRow) + position = Point(bufferRow, Infinity) + loop + foldableRange = @tokenizedBuffer.getFoldableRangeContainingPoint(position, @getTabLength()) + if foldableRange + existingFolds = @displayLayer.foldsIntersectingBufferRange(Range(foldableRange.start, foldableRange.start)) + if existingFolds.length is 0 + @displayLayer.foldBufferRange(foldableRange) + else + firstExistingFoldRange = @displayLayer.bufferRangeForFold(existingFolds[0]) + if firstExistingFoldRange.start.isLessThan(position) + position = Point(firstExistingFoldRange.start.row, 0) + continue + return # Essential: Unfold all folds containing the given row in buffer coordinates. # # * `bufferRow` A {Number} unfoldBufferRow: (bufferRow) -> - @displayLayer.destroyFoldsIntersectingBufferRange(Range(Point(bufferRow, 0), Point(bufferRow, Infinity))) + position = Point(bufferRow, Infinity) + @displayLayer.destroyFoldsIntersectingBufferRange(Range(position, position)) # Extended: For each selection, fold the rows it intersects. foldSelectedLines: -> @@ -3342,18 +3355,25 @@ class TextEditor extends Model # Extended: Fold all foldable lines. foldAll: -> - @languageMode.foldAll() + @displayLayer.destroyAllFolds() + for range in @tokenizedBuffer.getFoldableRanges(@getTabLength()) + @displayLayer.foldBufferRange(range) + return # Extended: Unfold all existing folds. unfoldAll: -> - @languageMode.unfoldAll() + result = @displayLayer.destroyAllFolds() @scrollToCursorPosition() + result # Extended: Fold all foldable lines at the given indent level. # # * `level` A {Number}. foldAllAtIndentLevel: (level) -> - @languageMode.foldAllAtIndentLevel(level) + @displayLayer.destroyAllFolds() + for range in @tokenizedBuffer.getFoldableRangesAtIndentLevel(level, @getTabLength()) + @displayLayer.foldBufferRange(range) + return # Extended: Determine whether the given row in buffer coordinates is foldable. # @@ -3547,6 +3567,7 @@ class TextEditor extends Model # for specific syntactic scopes. See the `ScopedSettingsDelegate` in # `text-editor-registry.js` for an example implementation. setScopedSettingsDelegate: (@scopedSettingsDelegate) -> + @tokenizedBuffer.scopedSettingsDelegate = this.scopedSettingsDelegate # Experimental: Retrieve the {Object} that provides the editor with settings # for specific syntactic scopes. @@ -3603,18 +3624,6 @@ class TextEditor extends Model getCommentStrings: (scopes) -> @scopedSettingsDelegate?.getCommentStrings?(scopes) - getIncreaseIndentPattern: (scopes) -> - @scopedSettingsDelegate?.getIncreaseIndentPattern?(scopes) - - getDecreaseIndentPattern: (scopes) -> - @scopedSettingsDelegate?.getDecreaseIndentPattern?(scopes) - - getDecreaseNextIndentPattern: (scopes) -> - @scopedSettingsDelegate?.getDecreaseNextIndentPattern?(scopes) - - getFoldEndPattern: (scopes) -> - @scopedSettingsDelegate?.getFoldEndPattern?(scopes) - ### Section: Event Handlers ### @@ -3850,14 +3859,51 @@ class TextEditor extends Model Section: Language Mode Delegated Methods ### - suggestedIndentForBufferRow: (bufferRow, options) -> @languageMode.suggestedIndentForBufferRow(bufferRow, options) + suggestedIndentForBufferRow: (bufferRow, options) -> @tokenizedBuffer.suggestedIndentForBufferRow(bufferRow, options) - autoIndentBufferRow: (bufferRow, options) -> @languageMode.autoIndentBufferRow(bufferRow, options) + # Given a buffer row, indent it. + # + # * bufferRow - The row {Number}. + # * options - An options {Object} to pass through to {TextEditor::setIndentationForBufferRow}. + autoIndentBufferRow: (bufferRow, options) -> + indentLevel = @suggestedIndentForBufferRow(bufferRow, options) + @setIndentationForBufferRow(bufferRow, indentLevel, options) - autoIndentBufferRows: (startRow, endRow) -> @languageMode.autoIndentBufferRows(startRow, endRow) + # Indents all the rows between two buffer row numbers. + # + # * startRow - The row {Number} to start at + # * endRow - The row {Number} to end at + autoIndentBufferRows: (startRow, endRow) -> + row = startRow + while row <= endRow + @autoIndentBufferRow(row) + row++ + return - autoDecreaseIndentForBufferRow: (bufferRow) -> @languageMode.autoDecreaseIndentForBufferRow(bufferRow) + autoDecreaseIndentForBufferRow: (bufferRow) -> + indentLevel = @tokenizedBuffer.suggestedIndentForEditedBufferRow(bufferRow) + @setIndentationForBufferRow(bufferRow, indentLevel) if indentLevel? - toggleLineCommentForBufferRow: (row) -> @languageMode.toggleLineCommentsForBufferRow(row) + toggleLineCommentForBufferRow: (row) -> @toggleLineCommentsForBufferRows(row, row) - toggleLineCommentsForBufferRows: (start, end) -> @languageMode.toggleLineCommentsForBufferRows(start, end) + toggleLineCommentsForBufferRows: (start, end) -> @tokenizedBuffer.toggleLineCommentsForBufferRows(start, end) + + rowRangeForParagraphAtBufferRow: (bufferRow) -> + return unless NON_WHITESPACE_REGEXP.test(@lineTextForBufferRow(bufferRow)) + + isCommented = @tokenizedBuffer.isRowCommented(bufferRow) + + startRow = bufferRow + while startRow > 0 + break unless NON_WHITESPACE_REGEXP.test(@lineTextForBufferRow(startRow - 1)) + break if @tokenizedBuffer.isRowCommented(startRow - 1) isnt isCommented + startRow-- + + endRow = bufferRow + rowCount = @getLineCount() + while endRow < rowCount + break unless NON_WHITESPACE_REGEXP.test(@lineTextForBufferRow(endRow + 1)) + break if @tokenizedBuffer.isRowCommented(endRow + 1) isnt isCommented + endRow++ + + new Range(new Point(startRow, 0), new Point(endRow, @buffer.lineLengthForRow(endRow))) diff --git a/src/tokenized-buffer.coffee b/src/tokenized-buffer.coffee deleted file mode 100644 index e4d954a59..000000000 --- a/src/tokenized-buffer.coffee +++ /dev/null @@ -1,455 +0,0 @@ -_ = require 'underscore-plus' -{CompositeDisposable, Emitter} = require 'event-kit' -{Point, Range} = require 'text-buffer' -Model = require './model' -TokenizedLine = require './tokenized-line' -TokenIterator = require './token-iterator' -ScopeDescriptor = require './scope-descriptor' -TokenizedBufferIterator = require './tokenized-buffer-iterator' -NullGrammar = require './null-grammar' -{toFirstMateScopeId} = require './first-mate-helpers' - -prefixedScopes = new Map() - -module.exports = -class TokenizedBuffer extends Model - grammar: null - buffer: null - tabLength: null - tokenizedLines: null - chunkSize: 50 - invalidRows: null - visible: false - changeCount: 0 - - @deserialize: (state, atomEnvironment) -> - buffer = null - if state.bufferId - buffer = atomEnvironment.project.bufferForIdSync(state.bufferId) - else - # TODO: remove this fallback after everyone transitions to the latest version. - buffer = atomEnvironment.project.bufferForPathSync(state.bufferPath) - return null unless buffer? - - state.buffer = buffer - state.assert = atomEnvironment.assert - new this(state) - - constructor: (params) -> - {grammar, @buffer, @tabLength, @largeFileMode, @assert} = params - - @emitter = new Emitter - @disposables = new CompositeDisposable - @tokenIterator = new TokenIterator(this) - - @disposables.add @buffer.registerTextDecorationLayer(this) - - @setGrammar(grammar ? NullGrammar) - - destroyed: -> - @disposables.dispose() - @tokenizedLines.length = 0 - - buildIterator: -> - new TokenizedBufferIterator(this) - - classNameForScopeId: (id) -> - scope = @grammar.scopeForId(toFirstMateScopeId(id)) - if scope - prefixedScope = prefixedScopes.get(scope) - if prefixedScope - prefixedScope - else - prefixedScope = "syntax--#{scope.replace(/\./g, ' syntax--')}" - prefixedScopes.set(scope, prefixedScope) - prefixedScope - else - null - - getInvalidatedRanges: -> - [] - - onDidInvalidateRange: (fn) -> - @emitter.on 'did-invalidate-range', fn - - serialize: -> - { - deserializer: 'TokenizedBuffer' - bufferPath: @buffer.getPath() - bufferId: @buffer.getId() - tabLength: @tabLength - largeFileMode: @largeFileMode - } - - observeGrammar: (callback) -> - callback(@grammar) - @onDidChangeGrammar(callback) - - onDidChangeGrammar: (callback) -> - @emitter.on 'did-change-grammar', callback - - onDidTokenize: (callback) -> - @emitter.on 'did-tokenize', callback - - setGrammar: (grammar) -> - return unless grammar? and grammar isnt @grammar - - @grammar = grammar - @rootScopeDescriptor = new ScopeDescriptor(scopes: [@grammar.scopeName]) - - @grammarUpdateDisposable?.dispose() - @grammarUpdateDisposable = @grammar.onDidUpdate => @retokenizeLines() - @disposables.add(@grammarUpdateDisposable) - - @retokenizeLines() - - @emitter.emit 'did-change-grammar', grammar - - getGrammarSelectionContent: -> - @buffer.getTextInRange([[0, 0], [10, 0]]) - - hasTokenForSelector: (selector) -> - for tokenizedLine in @tokenizedLines when tokenizedLine? - for token in tokenizedLine.tokens - return true if selector.matches(token.scopes) - false - - retokenizeLines: -> - return unless @alive - @fullyTokenized = false - @tokenizedLines = new Array(@buffer.getLineCount()) - @invalidRows = [] - if @largeFileMode or @grammar.name is 'Null Grammar' - @markTokenizationComplete() - else - @invalidateRow(0) - - setVisible: (@visible) -> - if @visible and @grammar.name isnt 'Null Grammar' and not @largeFileMode - @tokenizeInBackground() - - getTabLength: -> @tabLength - - setTabLength: (@tabLength) -> - - tokenizeInBackground: -> - return if not @visible or @pendingChunk or not @isAlive() - - @pendingChunk = true - _.defer => - @pendingChunk = false - @tokenizeNextChunk() if @isAlive() and @buffer.isAlive() - - tokenizeNextChunk: -> - rowsRemaining = @chunkSize - - while @firstInvalidRow()? and rowsRemaining > 0 - startRow = @invalidRows.shift() - lastRow = @getLastRow() - continue if startRow > lastRow - - row = startRow - loop - previousStack = @stackForRow(row) - @tokenizedLines[row] = @buildTokenizedLineForRow(row, @stackForRow(row - 1), @openScopesForRow(row)) - if --rowsRemaining is 0 - filledRegion = false - endRow = row - break - if row is lastRow or _.isEqual(@stackForRow(row), previousStack) - filledRegion = true - endRow = row - break - row++ - - @validateRow(endRow) - @invalidateRow(endRow + 1) unless filledRegion - - @emitter.emit 'did-invalidate-range', Range(Point(startRow, 0), Point(endRow + 1, 0)) - - if @firstInvalidRow()? - @tokenizeInBackground() - else - @markTokenizationComplete() - - markTokenizationComplete: -> - unless @fullyTokenized - @emitter.emit 'did-tokenize' - @fullyTokenized = true - - firstInvalidRow: -> - @invalidRows[0] - - validateRow: (row) -> - @invalidRows.shift() while @invalidRows[0] <= row - return - - invalidateRow: (row) -> - @invalidRows.push(row) - @invalidRows.sort (a, b) -> a - b - @tokenizeInBackground() - - updateInvalidRows: (start, end, delta) -> - @invalidRows = @invalidRows.map (row) -> - if row < start - row - else if start <= row <= end - end + delta + 1 - else if row > end - row + delta - - bufferDidChange: (e) -> - @changeCount = @buffer.changeCount - - {oldRange, newRange} = e - start = oldRange.start.row - end = oldRange.end.row - delta = newRange.end.row - oldRange.end.row - oldLineCount = oldRange.end.row - oldRange.start.row + 1 - newLineCount = newRange.end.row - newRange.start.row + 1 - - @updateInvalidRows(start, end, delta) - previousEndStack = @stackForRow(end) # used in spill detection below - if @largeFileMode or @grammar.name is 'Null Grammar' - _.spliceWithArray(@tokenizedLines, start, oldLineCount, new Array(newLineCount)) - else - newTokenizedLines = @buildTokenizedLinesForRows(start, end + delta, @stackForRow(start - 1), @openScopesForRow(start)) - _.spliceWithArray(@tokenizedLines, start, oldLineCount, newTokenizedLines) - newEndStack = @stackForRow(end + delta) - if newEndStack and not _.isEqual(newEndStack, previousEndStack) - @invalidateRow(end + delta + 1) - - isFoldableAtRow: (row) -> - @isFoldableCodeAtRow(row) or @isFoldableCommentAtRow(row) - - # Returns a {Boolean} indicating whether the given buffer row starts - # a a foldable row range due to the code's indentation patterns. - isFoldableCodeAtRow: (row) -> - if 0 <= row <= @buffer.getLastRow() - nextRow = @buffer.nextNonBlankRow(row) - tokenizedLine = @tokenizedLines[row] - if @buffer.isRowBlank(row) or tokenizedLine?.isComment() or not nextRow? - false - else - @indentLevelForRow(nextRow) > @indentLevelForRow(row) - else - false - - isFoldableCommentAtRow: (row) -> - previousRow = row - 1 - nextRow = row + 1 - if nextRow > @buffer.getLastRow() - false - else - Boolean( - not (@tokenizedLines[previousRow]?.isComment()) and - @tokenizedLines[row]?.isComment() and - @tokenizedLines[nextRow]?.isComment() - ) - - buildTokenizedLinesForRows: (startRow, endRow, startingStack, startingopenScopes) -> - ruleStack = startingStack - openScopes = startingopenScopes - stopTokenizingAt = startRow + @chunkSize - tokenizedLines = for row in [startRow..endRow] by 1 - if (ruleStack or row is 0) and row < stopTokenizingAt - tokenizedLine = @buildTokenizedLineForRow(row, ruleStack, openScopes) - ruleStack = tokenizedLine.ruleStack - openScopes = @scopesFromTags(openScopes, tokenizedLine.tags) - else - tokenizedLine = undefined - tokenizedLine - - if endRow >= stopTokenizingAt - @invalidateRow(stopTokenizingAt) - @tokenizeInBackground() - - tokenizedLines - - buildTokenizedLineForRow: (row, ruleStack, openScopes) -> - @buildTokenizedLineForRowWithText(row, @buffer.lineForRow(row), ruleStack, openScopes) - - buildTokenizedLineForRowWithText: (row, text, ruleStack = @stackForRow(row - 1), openScopes = @openScopesForRow(row)) -> - lineEnding = @buffer.lineEndingForRow(row) - {tags, ruleStack} = @grammar.tokenizeLine(text, ruleStack, row is 0, false) - new TokenizedLine({openScopes, text, tags, ruleStack, lineEnding, @tokenIterator, @grammar}) - - tokenizedLineForRow: (bufferRow) -> - if 0 <= bufferRow <= @buffer.getLastRow() - if tokenizedLine = @tokenizedLines[bufferRow] - tokenizedLine - else - text = @buffer.lineForRow(bufferRow) - lineEnding = @buffer.lineEndingForRow(bufferRow) - tags = [@grammar.startIdForScope(@grammar.scopeName), text.length, @grammar.endIdForScope(@grammar.scopeName)] - @tokenizedLines[bufferRow] = new TokenizedLine({openScopes: [], text, tags, lineEnding, @tokenIterator, @grammar}) - - tokenizedLinesForRows: (startRow, endRow) -> - for row in [startRow..endRow] by 1 - @tokenizedLineForRow(row) - - stackForRow: (bufferRow) -> - @tokenizedLines[bufferRow]?.ruleStack - - openScopesForRow: (bufferRow) -> - if precedingLine = @tokenizedLines[bufferRow - 1] - @scopesFromTags(precedingLine.openScopes, precedingLine.tags) - else - [] - - scopesFromTags: (startingScopes, tags) -> - scopes = startingScopes.slice() - for tag in tags when tag < 0 - if (tag % 2) is -1 - scopes.push(tag) - else - matchingStartTag = tag + 1 - loop - break if scopes.pop() is matchingStartTag - if scopes.length is 0 - @assert false, "Encountered an unmatched scope end tag.", (error) => - error.metadata = { - grammarScopeName: @grammar.scopeName - unmatchedEndTag: @grammar.scopeForId(tag) - } - path = require 'path' - error.privateMetadataDescription = "The contents of `#{path.basename(@buffer.getPath())}`" - error.privateMetadata = { - filePath: @buffer.getPath() - fileContents: @buffer.getText() - } - break - scopes - - indentLevelForRow: (bufferRow) -> - line = @buffer.lineForRow(bufferRow) - indentLevel = 0 - - if line is '' - nextRow = bufferRow + 1 - lineCount = @getLineCount() - while nextRow < lineCount - nextLine = @buffer.lineForRow(nextRow) - unless nextLine is '' - indentLevel = Math.ceil(@indentLevelForLine(nextLine)) - break - nextRow++ - - previousRow = bufferRow - 1 - while previousRow >= 0 - previousLine = @buffer.lineForRow(previousRow) - unless previousLine is '' - indentLevel = Math.max(Math.ceil(@indentLevelForLine(previousLine)), indentLevel) - break - previousRow-- - - indentLevel - else - @indentLevelForLine(line) - - indentLevelForLine: (line) -> - indentLength = 0 - for char in line - if char is '\t' - indentLength += @getTabLength() - (indentLength % @getTabLength()) - else if char is ' ' - indentLength++ - else - break - - indentLength / @getTabLength() - - scopeDescriptorForPosition: (position) -> - {row, column} = @buffer.clipPosition(Point.fromObject(position)) - - iterator = @tokenizedLineForRow(row).getTokenIterator() - while iterator.next() - if iterator.getBufferEnd() > column - scopes = iterator.getScopes() - break - - # rebuild scope of last token if we iterated off the end - unless scopes? - scopes = iterator.getScopes() - scopes.push(iterator.getScopeEnds().reverse()...) - - new ScopeDescriptor({scopes}) - - tokenForPosition: (position) -> - {row, column} = Point.fromObject(position) - @tokenizedLineForRow(row).tokenAtBufferColumn(column) - - tokenStartPositionForPosition: (position) -> - {row, column} = Point.fromObject(position) - column = @tokenizedLineForRow(row).tokenStartColumnForBufferColumn(column) - new Point(row, column) - - bufferRangeForScopeAtPosition: (selector, position) -> - position = Point.fromObject(position) - - {openScopes, tags} = @tokenizedLineForRow(position.row) - scopes = openScopes.map (tag) => @grammar.scopeForId(tag) - - startColumn = 0 - for tag, tokenIndex in tags - if tag < 0 - if tag % 2 is -1 - scopes.push(@grammar.scopeForId(tag)) - else - scopes.pop() - else - endColumn = startColumn + tag - if endColumn >= position.column - break - else - startColumn = endColumn - - - return unless selectorMatchesAnyScope(selector, scopes) - - startScopes = scopes.slice() - for startTokenIndex in [(tokenIndex - 1)..0] by -1 - tag = tags[startTokenIndex] - if tag < 0 - if tag % 2 is -1 - startScopes.pop() - else - startScopes.push(@grammar.scopeForId(tag)) - else - break unless selectorMatchesAnyScope(selector, startScopes) - startColumn -= tag - - endScopes = scopes.slice() - for endTokenIndex in [(tokenIndex + 1)...tags.length] by 1 - tag = tags[endTokenIndex] - if tag < 0 - if tag % 2 is -1 - endScopes.push(@grammar.scopeForId(tag)) - else - endScopes.pop() - else - break unless selectorMatchesAnyScope(selector, endScopes) - endColumn += tag - - new Range(new Point(position.row, startColumn), new Point(position.row, endColumn)) - - # Gets the row number of the last line. - # - # Returns a {Number}. - getLastRow: -> - @buffer.getLastRow() - - getLineCount: -> - @buffer.getLineCount() - - logLines: (start=0, end=@buffer.getLastRow()) -> - for row in [start..end] - line = @tokenizedLines[row].text - console.log row, line, line.length - return - -selectorMatchesAnyScope = (selector, scopes) -> - targetClasses = selector.replace(/^\./, '').split('.') - _.any scopes, (scope) -> - scopeClasses = scope.split('.') - _.isSubset(targetClasses, scopeClasses) diff --git a/src/tokenized-buffer.js b/src/tokenized-buffer.js new file mode 100644 index 000000000..b4bc0d41c --- /dev/null +++ b/src/tokenized-buffer.js @@ -0,0 +1,875 @@ +const _ = require('underscore-plus') +const {CompositeDisposable, Emitter} = require('event-kit') +const {Point, Range} = require('text-buffer') +const TokenizedLine = require('./tokenized-line') +const TokenIterator = require('./token-iterator') +const ScopeDescriptor = require('./scope-descriptor') +const TokenizedBufferIterator = require('./tokenized-buffer-iterator') +const NullGrammar = require('./null-grammar') +const {OnigRegExp} = require('oniguruma') +const {toFirstMateScopeId} = require('./first-mate-helpers') + +const NON_WHITESPACE_REGEX = /\S/ + +let nextId = 0 +const prefixedScopes = new Map() + +module.exports = +class TokenizedBuffer { + static deserialize (state, atomEnvironment) { + const buffer = atomEnvironment.project.bufferForIdSync(state.bufferId) + if (!buffer) return null + + state.buffer = buffer + state.assert = atomEnvironment.assert + return new TokenizedBuffer(state) + } + + constructor (params) { + this.emitter = new Emitter() + this.disposables = new CompositeDisposable() + this.tokenIterator = new TokenIterator(this) + this.regexesByPattern = {} + + this.alive = true + this.visible = false + this.id = params.id != null ? params.id : nextId++ + this.buffer = params.buffer + this.tabLength = params.tabLength + this.largeFileMode = params.largeFileMode + this.assert = params.assert + this.scopedSettingsDelegate = params.scopedSettingsDelegate + + this.setGrammar(params.grammar || NullGrammar) + this.disposables.add(this.buffer.registerTextDecorationLayer(this)) + } + + destroy () { + if (!this.alive) return + this.alive = false + this.disposables.dispose() + this.tokenizedLines.length = 0 + } + + isAlive () { + return this.alive + } + + isDestroyed () { + return !this.alive + } + + /* + Section - auto-indent + */ + + // Get the suggested indentation level for an existing line in the buffer. + // + // * bufferRow - A {Number} indicating the buffer row + // + // Returns a {Number}. + suggestedIndentForBufferRow (bufferRow, options) { + const line = this.buffer.lineForRow(bufferRow) + const tokenizedLine = this.tokenizedLineForRow(bufferRow) + return this._suggestedIndentForTokenizedLineAtBufferRow(bufferRow, line, tokenizedLine, options) + } + + // Get the suggested indentation level for a given line of text, if it were inserted at the given + // row in the buffer. + // + // * bufferRow - A {Number} indicating the buffer row + // + // Returns a {Number}. + suggestedIndentForLineAtBufferRow (bufferRow, line, options) { + const tokenizedLine = this.buildTokenizedLineForRowWithText(bufferRow, line) + return this._suggestedIndentForTokenizedLineAtBufferRow(bufferRow, line, tokenizedLine, options) + } + + // Get the suggested indentation level for a line in the buffer on which the user is currently + // typing. This may return a different result from {::suggestedIndentForBufferRow} in order + // to avoid unexpected changes in indentation. It may also return undefined if no change should + // be made. + // + // * bufferRow - The row {Number} + // + // Returns a {Number}. + suggestedIndentForEditedBufferRow (bufferRow) { + const line = this.buffer.lineForRow(bufferRow) + const currentIndentLevel = this.indentLevelForLine(line) + if (currentIndentLevel === 0) return + + const scopeDescriptor = this.scopeDescriptorForPosition([bufferRow, 0]) + const decreaseIndentRegex = this.decreaseIndentRegexForScopeDescriptor(scopeDescriptor) + if (!decreaseIndentRegex) return + + if (!decreaseIndentRegex.testSync(line)) return + + const precedingRow = this.buffer.previousNonBlankRow(bufferRow) + if (precedingRow == null) return + + const precedingLine = this.buffer.lineForRow(precedingRow) + let desiredIndentLevel = this.indentLevelForLine(precedingLine) + + const increaseIndentRegex = this.increaseIndentRegexForScopeDescriptor(scopeDescriptor) + if (increaseIndentRegex) { + if (!increaseIndentRegex.testSync(precedingLine)) desiredIndentLevel -= 1 + } + + const decreaseNextIndentRegex = this.decreaseNextIndentRegexForScopeDescriptor(scopeDescriptor) + if (decreaseNextIndentRegex) { + if (decreaseNextIndentRegex.testSync(precedingLine)) desiredIndentLevel -= 1 + } + + if (desiredIndentLevel < 0) return 0 + if (desiredIndentLevel >= currentIndentLevel) return + return desiredIndentLevel + } + + _suggestedIndentForTokenizedLineAtBufferRow (bufferRow, line, tokenizedLine, options) { + const iterator = tokenizedLine.getTokenIterator() + iterator.next() + const scopeDescriptor = new ScopeDescriptor({scopes: iterator.getScopes()}) + + const increaseIndentRegex = this.increaseIndentRegexForScopeDescriptor(scopeDescriptor) + const decreaseIndentRegex = this.decreaseIndentRegexForScopeDescriptor(scopeDescriptor) + const decreaseNextIndentRegex = this.decreaseNextIndentRegexForScopeDescriptor(scopeDescriptor) + + let precedingRow + if (!options || options.skipBlankLines !== false) { + precedingRow = this.buffer.previousNonBlankRow(bufferRow) + if (precedingRow == null) return 0 + } else { + precedingRow = bufferRow - 1 + if (precedingRow < 0) return 0 + } + + const precedingLine = this.buffer.lineForRow(precedingRow) + let desiredIndentLevel = this.indentLevelForLine(precedingLine) + if (!increaseIndentRegex) return desiredIndentLevel + + if (!this.isRowCommented(precedingRow)) { + if (increaseIndentRegex && increaseIndentRegex.testSync(precedingLine)) desiredIndentLevel += 1 + if (decreaseNextIndentRegex && decreaseNextIndentRegex.testSync(precedingLine)) desiredIndentLevel -= 1 + } + + if (!this.buffer.isRowBlank(precedingRow)) { + if (decreaseIndentRegex && decreaseIndentRegex.testSync(line)) desiredIndentLevel -= 1 + } + + return Math.max(desiredIndentLevel, 0) + } + + /* + Section - Comments + */ + + toggleLineCommentsForBufferRows (start, end) { + const scope = this.scopeDescriptorForPosition([start, 0]) + const commentStrings = this.commentStringsForScopeDescriptor(scope) + if (!commentStrings) return + const {commentStartString, commentEndString} = commentStrings + if (!commentStartString) return + + const commentStartRegexString = _.escapeRegExp(commentStartString).replace(/(\s+)$/, '(?:$1)?') + const commentStartRegex = new OnigRegExp(`^(\\s*)(${commentStartRegexString})`) + + if (commentEndString) { + const shouldUncomment = commentStartRegex.testSync(this.buffer.lineForRow(start)) + if (shouldUncomment) { + const commentEndRegexString = _.escapeRegExp(commentEndString).replace(/^(\s+)/, '(?:$1)?') + const commentEndRegex = new OnigRegExp(`(${commentEndRegexString})(\\s*)$`) + const startMatch = commentStartRegex.searchSync(this.buffer.lineForRow(start)) + const endMatch = commentEndRegex.searchSync(this.buffer.lineForRow(end)) + if (startMatch && endMatch) { + this.buffer.transact(() => { + const columnStart = startMatch[1].length + const columnEnd = columnStart + startMatch[2].length + this.buffer.setTextInRange([[start, columnStart], [start, columnEnd]], '') + + const endLength = this.buffer.lineLengthForRow(end) - endMatch[2].length + const endColumn = endLength - endMatch[1].length + return this.buffer.setTextInRange([[end, endColumn], [end, endLength]], '') + }) + } + } else { + this.buffer.transact(() => { + const indentLength = this.buffer.lineForRow(start).match(/^\s*/)[0].length + this.buffer.insert([start, indentLength], commentStartString) + this.buffer.insert([end, this.buffer.lineLengthForRow(end)], commentEndString) + }) + } + } else { + let hasCommentedLines = false + let hasUncommentedLines = false + for (let row = start; row <= end; row++) { + const line = this.buffer.lineForRow(row) + if (NON_WHITESPACE_REGEX.test(line)) { + if (commentStartRegex.testSync(line)) { + hasCommentedLines = true + } else { + hasUncommentedLines = true + } + } + } + + const shouldUncomment = hasCommentedLines && !hasUncommentedLines + + if (shouldUncomment) { + for (let row = start; row <= end; row++) { + const match = commentStartRegex.searchSync(this.buffer.lineForRow(row)) + if (match) { + const columnStart = match[1].length + const columnEnd = columnStart + match[2].length + this.buffer.setTextInRange([[row, columnStart], [row, columnEnd]], '') + } + } + } else { + let minIndentLevel = Infinity + let minBlankIndentLevel = Infinity + for (let row = start; row <= end; row++) { + const line = this.buffer.lineForRow(row) + const indentLevel = this.indentLevelForLine(line) + if (NON_WHITESPACE_REGEX.test(line)) { + if (indentLevel < minIndentLevel) minIndentLevel = indentLevel + } else { + if (indentLevel < minBlankIndentLevel) minBlankIndentLevel = indentLevel + } + } + minIndentLevel = Number.isFinite(minIndentLevel) + ? minIndentLevel + : Number.isFinite(minBlankIndentLevel) + ? minBlankIndentLevel + : 0 + + const tabLength = this.getTabLength() + const indentString = ' '.repeat(tabLength * minIndentLevel) + for (let row = start; row <= end; row++) { + const line = this.buffer.lineForRow(row) + if (NON_WHITESPACE_REGEX.test(line)) { + const indentColumn = this.columnForIndentLevel(line, minIndentLevel) + this.buffer.insert(Point(row, indentColumn), commentStartString) + } else { + this.buffer.setTextInRange( + new Range(new Point(row, 0), new Point(row, Infinity)), + indentString + commentStartString + ) + } + } + } + } + } + + buildIterator () { + return new TokenizedBufferIterator(this) + } + + classNameForScopeId (id) { + const scope = this.grammar.scopeForId(toFirstMateScopeId(id)) + if (scope) { + let prefixedScope = prefixedScopes.get(scope) + if (prefixedScope) { + return prefixedScope + } else { + prefixedScope = `syntax--${scope.replace(/\./g, ' syntax--')}` + prefixedScopes.set(scope, prefixedScope) + return prefixedScope + } + } else { + return null + } + } + + getInvalidatedRanges () { + return [] + } + + onDidInvalidateRange (fn) { + return this.emitter.on('did-invalidate-range', fn) + } + + serialize () { + return { + deserializer: 'TokenizedBuffer', + bufferPath: this.buffer.getPath(), + bufferId: this.buffer.getId(), + tabLength: this.tabLength, + largeFileMode: this.largeFileMode + } + } + + observeGrammar (callback) { + callback(this.grammar) + return this.onDidChangeGrammar(callback) + } + + onDidChangeGrammar (callback) { + return this.emitter.on('did-change-grammar', callback) + } + + onDidTokenize (callback) { + return this.emitter.on('did-tokenize', callback) + } + + setGrammar (grammar) { + if (!grammar || grammar === this.grammar) return + + this.grammar = grammar + this.rootScopeDescriptor = new ScopeDescriptor({scopes: [this.grammar.scopeName]}) + + if (this.grammarUpdateDisposable) this.grammarUpdateDisposable.dispose() + this.grammarUpdateDisposable = this.grammar.onDidUpdate(() => this.retokenizeLines()) + this.disposables.add(this.grammarUpdateDisposable) + + this.retokenizeLines() + this.emitter.emit('did-change-grammar', grammar) + } + + getGrammarSelectionContent () { + return this.buffer.getTextInRange([[0, 0], [10, 0]]) + } + + hasTokenForSelector (selector) { + for (const tokenizedLine of this.tokenizedLines) { + if (tokenizedLine) { + for (let token of tokenizedLine.tokens) { + if (selector.matches(token.scopes)) return true + } + } + } + return false + } + + retokenizeLines () { + if (!this.alive) return + this.fullyTokenized = false + this.tokenizedLines = new Array(this.buffer.getLineCount()) + this.invalidRows = [] + if (this.largeFileMode || this.grammar.name === 'Null Grammar') { + this.markTokenizationComplete() + } else { + this.invalidateRow(0) + } + } + + setVisible (visible) { + this.visible = visible + if (this.visible && this.grammar.name !== 'Null Grammar' && !this.largeFileMode) { + this.tokenizeInBackground() + } + } + + getTabLength () { return this.tabLength } + + setTabLength (tabLength) { + this.tabLength = tabLength + } + + tokenizeInBackground () { + if (!this.visible || this.pendingChunk || !this.alive) return + + this.pendingChunk = true + _.defer(() => { + this.pendingChunk = false + if (this.isAlive() && this.buffer.isAlive()) this.tokenizeNextChunk() + }) + } + + tokenizeNextChunk () { + let rowsRemaining = this.chunkSize + + while (this.firstInvalidRow() != null && rowsRemaining > 0) { + var endRow, filledRegion + const startRow = this.invalidRows.shift() + const lastRow = this.buffer.getLastRow() + if (startRow > lastRow) continue + + let row = startRow + while (true) { + const previousStack = this.stackForRow(row) + this.tokenizedLines[row] = this.buildTokenizedLineForRow(row, this.stackForRow(row - 1), this.openScopesForRow(row)) + if (--rowsRemaining === 0) { + filledRegion = false + endRow = row + break + } + if (row === lastRow || _.isEqual(this.stackForRow(row), previousStack)) { + filledRegion = true + endRow = row + break + } + row++ + } + + this.validateRow(endRow) + if (!filledRegion) this.invalidateRow(endRow + 1) + + this.emitter.emit('did-invalidate-range', Range(Point(startRow, 0), Point(endRow + 1, 0))) + } + + if (this.firstInvalidRow() != null) { + this.tokenizeInBackground() + } else { + this.markTokenizationComplete() + } + } + + markTokenizationComplete () { + if (!this.fullyTokenized) { + this.emitter.emit('did-tokenize') + } + this.fullyTokenized = true + } + + firstInvalidRow () { + return this.invalidRows[0] + } + + validateRow (row) { + while (this.invalidRows[0] <= row) this.invalidRows.shift() + } + + invalidateRow (row) { + this.invalidRows.push(row) + this.invalidRows.sort((a, b) => a - b) + this.tokenizeInBackground() + } + + updateInvalidRows (start, end, delta) { + this.invalidRows = this.invalidRows.map((row) => { + if (row < start) { + return row + } else if (start <= row && row <= end) { + return end + delta + 1 + } else if (row > end) { + return row + delta + } + }) + } + + bufferDidChange (e) { + this.changeCount = this.buffer.changeCount + + const {oldRange, newRange} = e + const start = oldRange.start.row + const end = oldRange.end.row + const delta = newRange.end.row - oldRange.end.row + const oldLineCount = (oldRange.end.row - oldRange.start.row) + 1 + const newLineCount = (newRange.end.row - newRange.start.row) + 1 + + this.updateInvalidRows(start, end, delta) + const previousEndStack = this.stackForRow(end) // used in spill detection below + if (this.largeFileMode || (this.grammar.name === 'Null Grammar')) { + _.spliceWithArray(this.tokenizedLines, start, oldLineCount, new Array(newLineCount)) + } else { + const newTokenizedLines = this.buildTokenizedLinesForRows(start, end + delta, this.stackForRow(start - 1), this.openScopesForRow(start)) + _.spliceWithArray(this.tokenizedLines, start, oldLineCount, newTokenizedLines) + const newEndStack = this.stackForRow(end + delta) + if (newEndStack && !_.isEqual(newEndStack, previousEndStack)) { + this.invalidateRow(end + delta + 1) + } + } + } + + isFoldableAtRow (row) { + return this.endRowForFoldAtRow(row, 1, true) != null + } + + buildTokenizedLinesForRows (startRow, endRow, startingStack, startingopenScopes) { + let ruleStack = startingStack + let openScopes = startingopenScopes + const stopTokenizingAt = startRow + this.chunkSize + const tokenizedLines = [] + for (let row = startRow, end = endRow; row <= end; row++) { + let tokenizedLine + if ((ruleStack || (row === 0)) && row < stopTokenizingAt) { + tokenizedLine = this.buildTokenizedLineForRow(row, ruleStack, openScopes) + ruleStack = tokenizedLine.ruleStack + openScopes = this.scopesFromTags(openScopes, tokenizedLine.tags) + } + tokenizedLines.push(tokenizedLine) + } + + if (endRow >= stopTokenizingAt) { + this.invalidateRow(stopTokenizingAt) + this.tokenizeInBackground() + } + + return tokenizedLines + } + + buildTokenizedLineForRow (row, ruleStack, openScopes) { + return this.buildTokenizedLineForRowWithText(row, this.buffer.lineForRow(row), ruleStack, openScopes) + } + + buildTokenizedLineForRowWithText (row, text, currentRuleStack = this.stackForRow(row - 1), openScopes = this.openScopesForRow(row)) { + const lineEnding = this.buffer.lineEndingForRow(row) + const {tags, ruleStack} = this.grammar.tokenizeLine(text, currentRuleStack, row === 0, false) + return new TokenizedLine({ + openScopes, + text, + tags, + ruleStack, + lineEnding, + tokenIterator: this.tokenIterator, + grammar: this.grammar + }) + } + + tokenizedLineForRow (bufferRow) { + if (bufferRow >= 0 && bufferRow <= this.buffer.getLastRow()) { + const tokenizedLine = this.tokenizedLines[bufferRow] + if (tokenizedLine) { + return tokenizedLine + } else { + const text = this.buffer.lineForRow(bufferRow) + const lineEnding = this.buffer.lineEndingForRow(bufferRow) + const tags = [ + this.grammar.startIdForScope(this.grammar.scopeName), + text.length, + this.grammar.endIdForScope(this.grammar.scopeName) + ] + this.tokenizedLines[bufferRow] = new TokenizedLine({ + openScopes: [], + text, + tags, + lineEnding, + tokenIterator: this.tokenIterator, + grammar: this.grammar + }) + return this.tokenizedLines[bufferRow] + } + } + } + + tokenizedLinesForRows (startRow, endRow) { + const result = [] + for (let row = startRow, end = endRow; row <= end; row++) { + result.push(this.tokenizedLineForRow(row)) + } + return result + } + + stackForRow (bufferRow) { + return this.tokenizedLines[bufferRow] && this.tokenizedLines[bufferRow].ruleStack + } + + openScopesForRow (bufferRow) { + const precedingLine = this.tokenizedLines[bufferRow - 1] + if (precedingLine) { + return this.scopesFromTags(precedingLine.openScopes, precedingLine.tags) + } else { + return [] + } + } + + scopesFromTags (startingScopes, tags) { + const scopes = startingScopes.slice() + for (const tag of tags) { + if (tag < 0) { + if (tag % 2 === -1) { + scopes.push(tag) + } else { + const matchingStartTag = tag + 1 + while (true) { + if (scopes.pop() === matchingStartTag) break + if (scopes.length === 0) { + this.assert(false, 'Encountered an unmatched scope end tag.', error => { + error.metadata = { + grammarScopeName: this.grammar.scopeName, + unmatchedEndTag: this.grammar.scopeForId(tag) + } + const path = require('path') + error.privateMetadataDescription = `The contents of \`${path.basename(this.buffer.getPath())}\`` + error.privateMetadata = { + filePath: this.buffer.getPath(), + fileContents: this.buffer.getText() + } + }) + break + } + } + } + } + } + return scopes + } + + columnForIndentLevel (line, indentLevel, tabLength = this.tabLength) { + let column = 0 + let indentLength = 0 + const goalIndentLength = indentLevel * tabLength + while (indentLength < goalIndentLength) { + const char = line[column] + if (char === '\t') { + indentLength += tabLength - (indentLength % tabLength) + } else if (char === ' ') { + indentLength++ + } else { + break + } + column++ + } + return column + } + + indentLevelForLine (line, tabLength = this.tabLength) { + let indentLength = 0 + for (let i = 0, {length} = line; i < length; i++) { + const char = line[i] + if (char === '\t') { + indentLength += tabLength - (indentLength % tabLength) + } else if (char === ' ') { + indentLength++ + } else { + break + } + } + return indentLength / tabLength + } + + scopeDescriptorForPosition (position) { + let scopes + const {row, column} = this.buffer.clipPosition(Point.fromObject(position)) + + const iterator = this.tokenizedLineForRow(row).getTokenIterator() + while (iterator.next()) { + if (iterator.getBufferEnd() > column) { + scopes = iterator.getScopes() + break + } + } + + // rebuild scope of last token if we iterated off the end + if (!scopes) { + scopes = iterator.getScopes() + scopes.push(...iterator.getScopeEnds().reverse()) + } + + return new ScopeDescriptor({scopes}) + } + + tokenForPosition (position) { + const {row, column} = Point.fromObject(position) + return this.tokenizedLineForRow(row).tokenAtBufferColumn(column) + } + + tokenStartPositionForPosition (position) { + let {row, column} = Point.fromObject(position) + column = this.tokenizedLineForRow(row).tokenStartColumnForBufferColumn(column) + return new Point(row, column) + } + + bufferRangeForScopeAtPosition (selector, position) { + let endColumn, tag, tokenIndex + position = Point.fromObject(position) + + const {openScopes, tags} = this.tokenizedLineForRow(position.row) + const scopes = openScopes.map(tag => this.grammar.scopeForId(tag)) + + let startColumn = 0 + for (tokenIndex = 0; tokenIndex < tags.length; tokenIndex++) { + tag = tags[tokenIndex] + if (tag < 0) { + if ((tag % 2) === -1) { + scopes.push(this.grammar.scopeForId(tag)) + } else { + scopes.pop() + } + } else { + endColumn = startColumn + tag + if (endColumn >= position.column) { + break + } else { + startColumn = endColumn + } + } + } + + if (!selectorMatchesAnyScope(selector, scopes)) return + + const startScopes = scopes.slice() + for (let startTokenIndex = tokenIndex - 1; startTokenIndex >= 0; startTokenIndex--) { + tag = tags[startTokenIndex] + if (tag < 0) { + if ((tag % 2) === -1) { + startScopes.pop() + } else { + startScopes.push(this.grammar.scopeForId(tag)) + } + } else { + if (!selectorMatchesAnyScope(selector, startScopes)) { break } + startColumn -= tag + } + } + + const endScopes = scopes.slice() + for (let endTokenIndex = tokenIndex + 1, end = tags.length; endTokenIndex < end; endTokenIndex++) { + tag = tags[endTokenIndex] + if (tag < 0) { + if ((tag % 2) === -1) { + endScopes.push(this.grammar.scopeForId(tag)) + } else { + endScopes.pop() + } + } else { + if (!selectorMatchesAnyScope(selector, endScopes)) { break } + endColumn += tag + } + } + + return new Range(new Point(position.row, startColumn), new Point(position.row, endColumn)) + } + + isRowCommented (row) { + return this.tokenizedLines[row] && this.tokenizedLines[row].isComment() + } + + getFoldableRangeContainingPoint (point, tabLength) { + if (point.column >= this.buffer.lineLengthForRow(point.row)) { + const endRow = this.endRowForFoldAtRow(point.row, tabLength) + if (endRow != null) { + return Range(Point(point.row, Infinity), Point(endRow, Infinity)) + } + } + + for (let row = point.row - 1; row >= 0; row--) { + const endRow = this.endRowForFoldAtRow(row, tabLength) + if (endRow != null && endRow > point.row) { + return Range(Point(row, Infinity), Point(endRow, Infinity)) + } + } + return null + } + + getFoldableRangesAtIndentLevel (indentLevel, tabLength) { + const result = [] + let row = 0 + const lineCount = this.buffer.getLineCount() + while (row < lineCount) { + if (this.indentLevelForLine(this.buffer.lineForRow(row), tabLength) === indentLevel) { + const endRow = this.endRowForFoldAtRow(row, tabLength) + if (endRow != null) { + result.push(Range(Point(row, Infinity), Point(endRow, Infinity))) + row = endRow + 1 + continue + } + } + row++ + } + return result + } + + getFoldableRanges (tabLength) { + const result = [] + let row = 0 + const lineCount = this.buffer.getLineCount() + while (row < lineCount) { + const endRow = this.endRowForFoldAtRow(row, tabLength) + if (endRow != null) { + result.push(Range(Point(row, Infinity), Point(endRow, Infinity))) + } + row++ + } + return result + } + + endRowForFoldAtRow (row, tabLength, existenceOnly = false) { + if (this.isRowCommented(row)) { + return this.endRowForCommentFoldAtRow(row, existenceOnly) + } else { + return this.endRowForCodeFoldAtRow(row, tabLength, existenceOnly) + } + } + + endRowForCommentFoldAtRow (row, existenceOnly) { + if (this.isRowCommented(row - 1)) return + + let endRow + for (let nextRow = row + 1, end = this.buffer.getLineCount(); nextRow < end; nextRow++) { + if (!this.isRowCommented(nextRow)) break + endRow = nextRow + if (existenceOnly) break + } + + return endRow + } + + endRowForCodeFoldAtRow (row, tabLength, existenceOnly) { + let foldEndRow + const line = this.buffer.lineForRow(row) + if (!NON_WHITESPACE_REGEX.test(line)) return + const startIndentLevel = this.indentLevelForLine(line, tabLength) + const scopeDescriptor = this.scopeDescriptorForPosition([row, 0]) + const foldEndRegex = this.foldEndRegexForScopeDescriptor(scopeDescriptor) + for (let nextRow = row + 1, end = this.buffer.getLineCount(); nextRow < end; nextRow++) { + const line = this.buffer.lineForRow(nextRow) + if (!NON_WHITESPACE_REGEX.test(line)) continue + const indentation = this.indentLevelForLine(line, tabLength) + if (indentation < startIndentLevel) { + break + } else if (indentation === startIndentLevel) { + if (foldEndRegex && foldEndRegex.searchSync(line)) foldEndRow = nextRow + break + } + foldEndRow = nextRow + if (existenceOnly) break + } + return foldEndRow + } + + increaseIndentRegexForScopeDescriptor (scopeDescriptor) { + if (this.scopedSettingsDelegate) { + return this.regexForPattern(this.scopedSettingsDelegate.getIncreaseIndentPattern(scopeDescriptor)) + } + } + + decreaseIndentRegexForScopeDescriptor (scopeDescriptor) { + if (this.scopedSettingsDelegate) { + return this.regexForPattern(this.scopedSettingsDelegate.getDecreaseIndentPattern(scopeDescriptor)) + } + } + + decreaseNextIndentRegexForScopeDescriptor (scopeDescriptor) { + if (this.scopedSettingsDelegate) { + return this.regexForPattern(this.scopedSettingsDelegate.getDecreaseNextIndentPattern(scopeDescriptor)) + } + } + + foldEndRegexForScopeDescriptor (scopes) { + if (this.scopedSettingsDelegate) { + return this.regexForPattern(this.scopedSettingsDelegate.getFoldEndPattern(scopes)) + } + } + + commentStringsForScopeDescriptor (scopes) { + if (this.scopedSettingsDelegate) { + return this.scopedSettingsDelegate.getCommentStrings(scopes) + } + } + + regexForPattern (pattern) { + if (pattern) { + if (!this.regexesByPattern[pattern]) { + this.regexesByPattern[pattern] = new OnigRegExp(pattern) + } + return this.regexesByPattern[pattern] + } + } + + logLines (start = 0, end = this.buffer.getLastRow()) { + for (let row = start; row <= end; row++) { + const line = this.tokenizedLines[row].text + console.log(row, line, line.length) + } + } +} + +module.exports.prototype.chunkSize = 50 + +function selectorMatchesAnyScope (selector, scopes) { + const targetClasses = selector.replace(/^\./, '').split('.') + return scopes.some((scope) => { + const scopeClasses = scope.split('.') + return _.isSubset(targetClasses, scopeClasses) + }) +}